diff --git a/.eslintrc.json b/.eslintrc.json index 88d626d..65f8018 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,8 @@ }, "globals": { "chai": false, - "expect": false + "expect": false, + "globalThis": false }, "env": { "mocha": true diff --git a/docs/index.html b/docs/index.html index fe525f8..3644e39 100644 --- a/docs/index.html +++ b/docs/index.html @@ -116,6 +116,7 @@ tr[data-transpiled] ~ tr td[data-code] ~ [data-supported="false"] div, tr[data-transpiled] ~ tr td[data-code][data-supported="false"] div, caption p span.polyfilled, + caption p span.buggy, caption p span.transpiled { background-color: #ddf4ff; color: #24292f; @@ -153,6 +154,8 @@

GitHub Feature Support Table

!Required feature, not available in this browser.

*Not avaible in this browser, but polyfilled using this library. +

+ Required feature, but polyfilled to smooth over bugs in this browser.

**Not available in this browser, but transpiled to a compatible syntax.

@@ -523,6 +526,20 @@

GitHub Feature Support Table

78+
16.0+
+ + + + ClipboardItem + + +
*
+
66+ †
+
79+ †
+
*
+
13.1+
+
53+ †
+
9.0+ †
+ @@ -565,6 +582,20 @@

GitHub Feature Support Table

76+
15.0+
+ + +
+ navigator.clipboard + + +
*
+
86+
+
79+
+
*
+
13.1+
+
63+ †
+
12.0+ †
+ diff --git a/src/clipboarditem.ts b/src/clipboarditem.ts new file mode 100644 index 0000000..d478806 --- /dev/null +++ b/src/clipboarditem.ts @@ -0,0 +1,47 @@ +type ClipboardItems = Record> +const records = new WeakMap() +const presentationStyles = new WeakMap() +export class ClipboardItem { + constructor(items: ClipboardItems, options: ClipboardItemOptions | undefined = {}) { + if (Object.keys(items).length === 0) throw new TypeError('Empty dictionary argument') + records.set(this, items) + presentationStyles.set(this, options.presentationStyle || 'unspecified') + } + + get presentationStyle(): PresentationStyle { + return presentationStyles.get(this) || 'unspecified' + } + + get types() { + return Object.freeze(Object.keys(records.get(this) || {})) + } + + async getType(type: string): Promise { + const record = records.get(this) + if (record && type in record) { + const item = await record[type]! + if (typeof item === 'string') return new Blob([item], {type}) + return item + } + throw new DOMException("Failed to execute 'getType' on 'ClipboardItem': The type was not found", 'NotFoundError') + } +} + +export function isSupported(): boolean { + try { + new globalThis.ClipboardItem({'text/plain': Promise.resolve('')}) + return true + } catch { + return false + } +} + +export function isPolyfilled(): boolean { + return globalThis.ClipboardItem === ClipboardItem +} + +export function apply(): void { + if (!isSupported()) { + globalThis.ClipboardItem = ClipboardItem + } +} diff --git a/src/index.ts b/src/index.ts index 3632807..7076201 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,11 @@ import * as abortSignalAbort from './abortsignal-abort.js' import * as abortSignalTimeout from './abortsignal-timeout.js' import * as aggregateError from './aggregateerror.js' import * as arrayAt from './arraylike-at.js' +import * as clipboardItem from './clipboarditem.js' import * as cryptoRandomUUID from './crypto-randomuuid.js' import * as elementReplaceChildren from './element-replacechildren.js' import * as eventAbortSignal from './event-abortsignal.js' +import * as navigatorClipboard from './navigator-clipboard.js' import * as formRequestSubmit from './form-requestsubmit.js' import * as objectHasOwn from './object-hasown.js' import * as promiseAllSettled from './promise-allsettled.js' @@ -58,9 +60,11 @@ export const polyfills = { abortSignalTimeout, aggregateError, arrayAt, + clipboardItem, cryptoRandomUUID, elementReplaceChildren, eventAbortSignal, + navigatorClipboard, formRequestSubmit, objectHasOwn, promiseAllSettled, diff --git a/src/navigator-clipboard.ts b/src/navigator-clipboard.ts new file mode 100644 index 0000000..2be7e7d --- /dev/null +++ b/src/navigator-clipboard.ts @@ -0,0 +1,26 @@ +export async function clipboardWrite(data: ClipboardItems) { + if (data.length === 0) return + const item = data[0] + const blob = await item.getType(item.types.includes('text/plain') ? 'text/plain' : item.types[0]) + return navigator.clipboard.writeText(typeof blob == 'string' ? blob : await blob.text()) +} + +export async function clipboardRead() { + const str = navigator.clipboard.readText() + return [new ClipboardItem({'text/plain': str})] +} + +export function isSupported(): boolean { + return typeof navigator.clipboard.read === 'function' && typeof navigator.clipboard.write === 'function' +} + +export function isPolyfilled(): boolean { + return navigator.clipboard.write === clipboardWrite || navigator.clipboard.read === clipboardRead +} + +export function apply(): void { + if (!isSupported()) { + navigator.clipboard.write = clipboardWrite + navigator.clipboard.read = clipboardRead + } +} diff --git a/test/clipboarditem.js b/test/clipboarditem.js new file mode 100644 index 0000000..d12cb32 --- /dev/null +++ b/test/clipboarditem.js @@ -0,0 +1,17 @@ +import {ClipboardItem, apply, isSupported, isPolyfilled} from '../lib/clipboarditem.js' + +describe('ClipboardItem', () => { + it('has standard isSupported, isPolyfilled, apply API', () => { + expect(isSupported).to.be.a('function') + expect(isPolyfilled).to.be.a('function') + expect(apply).to.be.a('function') + expect(isSupported()).to.be.a('boolean') + expect(isPolyfilled()).to.equal(false) + }) + + it('takes a Promise type, that can resolve', async () => { + const c = new ClipboardItem({'text/plain': Promise.resolve('hi')}) + expect(c.types).to.eql(['text/plain']) + expect(await c.getType('text/plain')).to.be.instanceof(Blob) + }) +}) diff --git a/test/navigator-clipboard.js b/test/navigator-clipboard.js new file mode 100644 index 0000000..aebb525 --- /dev/null +++ b/test/navigator-clipboard.js @@ -0,0 +1,54 @@ +import {clipboardRead, clipboardWrite, apply, isPolyfilled, isSupported} from '../lib/navigator-clipboard.js' + +describe('navigator clipboard', () => { + it('has standard isSupported, isPolyfilled, apply API', () => { + expect(isSupported).to.be.a('function') + expect(isPolyfilled).to.be.a('function') + expect(apply).to.be.a('function') + expect(isSupported()).to.be.a('boolean') + expect(isPolyfilled()).to.equal(false) + }) + + describe('read', () => { + it('read returns array of 1 clipboard entry with plaintext of readText value', async () => { + navigator.clipboard.readText = () => Promise.resolve('foo') + const arr = await clipboardRead() + expect(arr).to.have.lengthOf(1) + expect(arr[0]).to.be.an.instanceof(globalThis.ClipboardItem) + expect(arr[0].types).to.eql(['text/plain']) + expect(await arr[0].getType('text/plain')).to.eql('foo') + }) + }) + + describe('write', () => { + it('unpacks text/plain content to writeText', async () => { + const calls = [] + navigator.clipboard.writeText = (...args) => calls.push(args) + await clipboardWrite([ + new globalThis.ClipboardItem({ + 'foo/bar': 'horrible', + 'text/plain': Promise.resolve('foo') + }) + ]) + expect(calls).to.have.lengthOf(1) + expect(calls[0]).to.eql(['foo']) + }) + + it('accepts multiple clipboard items, picking the first', async () => { + const calls = [] + navigator.clipboard.writeText = (...args) => calls.push(args) + await clipboardWrite([ + new globalThis.ClipboardItem({ + 'foo/bar': 'horrible', + 'text/plain': Promise.resolve('multiple-pass') + }), + new globalThis.ClipboardItem({ + 'foo/bar': 'multiple-fail', + 'text/plain': Promise.resolve('multiple-fail') + }) + ]) + expect(calls).to.have.lengthOf(1) + expect(calls[0]).to.eql(['multiple-pass']) + }) + }) +})