├── .github └── workflows │ ├── CI.yml │ └── publish-github-release.yaml ├── .gitignore ├── Makefile ├── README.md ├── biome.json ├── bun.lock ├── clipboard-polyfill-logo.svg ├── experiment ├── Conclusions.md ├── experiment.css ├── experiment.html └── experiment.ts ├── overwrite-globals └── package.json ├── package.json ├── script ├── build-demo.ts ├── build.ts ├── dev.ts ├── mock-test.bash └── test-bun.ts ├── src ├── clipboard-polyfill │ ├── ClipboardItem │ │ ├── ClipboardItemPolyfill.ts │ │ ├── check.ts │ │ ├── convert.ts │ │ ├── data-types.ts │ │ └── spec.ts │ ├── builtins │ │ ├── builtin-globals.ts │ │ ├── promise-constructor.ts │ │ └── window-globalThis.ts │ ├── debug.ts │ ├── entries │ │ ├── es5 │ │ │ ├── overwrite-globals.promise.ts │ │ │ ├── overwrite-globals.ts │ │ │ ├── window-var.promise.ts │ │ │ └── window-var.ts │ │ └── es6 │ │ │ └── clipboard-polyfill.es6.ts │ ├── implementations │ │ ├── blob.ts │ │ ├── text.blank-document.test.ts │ │ ├── text.blank-environment.test.ts │ │ ├── text.execCommand-fallback.test.ts │ │ ├── text.modern-browser-async-api-disabled.test.ts │ │ ├── text.modern-browser.test.ts │ │ ├── text.ts │ │ └── write-fallback.ts │ ├── promise │ │ ├── es6-promise.d.ts │ │ ├── polyfill.ts │ │ ├── promise-compat.ts │ │ └── set-promise-polyfill-if-needed.ts │ └── strategies │ │ ├── dom.ts │ │ └── internet-explorer.ts ├── demo │ ├── clipboard-polyfill-logo.svg │ ├── demo.ts │ ├── index.css │ ├── index.html │ └── readme-examples │ │ ├── main.html │ │ └── main.ts ├── mock-test │ ├── missing-Promise.ts │ └── modern-writeText.ts └── test │ ├── bun-test-cannot-run-all-tests.test.ts │ └── mocks.ts └── tsconfig.json /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Use Node.js 19.4 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 19.4 15 | - uses: oven-sh/setup-bun@v1 16 | - run: make setup 17 | - run: make build 18 | - run: make build-demo 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Use Node.js 19.4 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 19.4 29 | - uses: oven-sh/setup-bun@v1 30 | - run: make setup 31 | - run: make lint 32 | - run: make test-no-es6-browser-globals 33 | - run: make test-bun 34 | - run: make mock-test 35 | - run: make format 36 | -------------------------------------------------------------------------------- /.github/workflows/publish-github-release.yaml: -------------------------------------------------------------------------------- 1 | name: Publish GitHub release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | Publish: 10 | permissions: 11 | contents: write 12 | if: startsWith(github.ref, 'refs/tags/v') 13 | uses: cubing/actions-workflows/.github/workflows/publish-github-release.yaml@main 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.temp 2 | /dist 3 | /node_modules 4 | /package-lock.json 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: build 3 | build: build-js build-types 4 | 5 | .PHONY: build-js 6 | build-js: setup 7 | bun run script/build.ts 8 | 9 | .PHONY: build-types 10 | build-types: setup 11 | bun x tsc --project tsconfig.json 12 | 13 | .PHONY: setup 14 | setup: 15 | bun install --frozen-lockfile 16 | 17 | .PHONY: build-demo 18 | build-demo: setup 19 | bun run script/build-demo.ts 20 | 21 | .PHONY: test 22 | test: build build-demo lint test-bun test-no-es6-browser-globals 23 | 24 | .PHONY: mock-test 25 | mock-test: setup 26 | ./script/mock-test.bash 27 | 28 | .PHONY: test-no-es6-browser-globals 29 | test-no-es6-browser-globals: build-js 30 | bun run dist/es6/clipboard-polyfill.es6.js 31 | 32 | .PHONY: test-bun 33 | test-bun: setup 34 | bun script/test-bun.ts 35 | 36 | .PHONY: dev 37 | dev: setup 38 | bun run script/dev.ts 39 | 40 | .PHONY: lint 41 | lint: setup 42 | bun x @biomejs/biome check 43 | 44 | .PHONY: format 45 | format: setup 46 | bun x @biomejs/biome format --write 47 | 48 | .PHONY: clean 49 | clean: 50 | rm -rf ./dist 51 | 52 | .PHONY: reset 53 | reset: clean 54 | rm -rf ./node_modules 55 | 56 | .PHONY: prepublishOnly 57 | prepublishOnly: clean build 58 | 59 | # This is here because `npm` has issues with a `script` field named `publish`. 60 | .PHONY: publish 61 | publish: 62 | npm publish 63 | 64 | .PHONY: deploy 65 | deploy: build-demo 66 | bun x @cubing/deploy 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo for clipboard-polyfill: an icon of a clipboard fading into a drafting paper grid.](clipboard-polyfill-logo.svg) 2 | 3 | # `clipboard-polyfill` 4 | 5 | ## ⚠️ You don't need `clipboard-polyfill` to copy text! ⚠️ 6 | 7 | Note: As of 2020, you can use `navigator.clipboard.writeText(...)` [in the stable versions of all major browsers](https://caniuse.com/mdn-api_clipboard_writetext). This library will only be useful to you if you want to: 8 | 9 | - target very old browsers (see below for compatibility) for text copy, 10 | - copy `text/html` in Firefox ≤126, 11 | - use the `ClipboardItem` API in Firefox ≤126, or 12 | - polyfill the API shape in a non-browser environment (e.g. in [`jsdom`](https://github.com/jsdom/jsdom/issues/1568)). 13 | 14 | See the [Compatibility section](#compatibility) below for more details. 15 | 16 | --- 17 | 18 | ## Summary 19 | 20 | Makes copying on the web as easy as: 21 | 22 | ```js 23 | clipboard.writeText("hello world"); 24 | ``` 25 | 26 | This library is a [ponyfill](https://github.com/sindresorhus/ponyfill)/polyfill for the modern `Promise`-based [asynchronous clipboard API](https://www.w3.org/TR/clipboard-apis/#async-clipboard-api). 27 | 28 | ## Usage 29 | 30 | If you use `npm`, install: 31 | 32 | ```shell 33 | npm install clipboard-polyfill 34 | ``` 35 | 36 | Sample app that copies text to the clipboard: 37 | 38 | ```js 39 | import * as clipboard from "clipboard-polyfill"; 40 | 41 | function handler() { 42 | clipboard.writeText("This text is plain.").then( 43 | () => { console.log("success!"); }, 44 | () => { console.log("error!"); } 45 | ); 46 | } 47 | 48 | window.addEventListener("DOMContentLoaded", function () { 49 | const button = document.body.appendChild(document.createElement("button")); 50 | button.textContent = "Copy"; 51 | button.addEventListener("click", handler); 52 | }); 53 | ``` 54 | 55 | Notes: 56 | 57 | - You need to call a clipboard operation in response to a user gesture (e.g. the event handler for a `button` click). 58 | - Some browsers may only allow one clipboard operation per gesture. 59 | 60 | ## `async`/`await` syntax 61 | 62 | ```js 63 | import * as clipboard from "clipboard-polyfill"; 64 | 65 | async function handler() { 66 | console.log("Previous clipboard text:", await clipboard.readText()); 67 | 68 | await clipboard.writeText("This text is plain."); 69 | } 70 | 71 | window.addEventListener("DOMContentLoaded", function () { 72 | const button = document.body.appendChild(document.createElement("button")); 73 | button.textContent = "Copy"; 74 | button.addEventListener("click", handler); 75 | }); 76 | ``` 77 | 78 | ## More MIME types (data types) 79 | 80 | ```js 81 | import * as clipboard from "clipboard-polyfill"; 82 | 83 | async function handler() { 84 | console.log("Previous clipboard contents:", await clipboard.read()); 85 | 86 | const item = new clipboard.ClipboardItem({ 87 | "text/html": new Blob( 88 | ["Markup text. Paste me into a rich text editor."], 89 | { type: "text/html" } 90 | ), 91 | "text/plain": new Blob( 92 | ["Fallback markup text. Paste me into a rich text editor."], 93 | { type: "text/plain" } 94 | ), 95 | }); 96 | await clipboard.write([item]); 97 | } 98 | 99 | window.addEventListener("DOMContentLoaded", function () { 100 | const button = document.body.appendChild(document.createElement("button")); 101 | button.textContent = "Copy"; 102 | button.addEventListener("click", handler); 103 | }); 104 | ``` 105 | 106 | Check [the Clipboard API specification](https://www.w3.org/TR/clipboard-apis/#clipboard-interface) for more details. 107 | 108 | Notes: 109 | 110 | - You'll need to use `async` functions for the `await` syntax. 111 | - Currently, `text/plain` and `text/html` are the only data types that can be written to the clipboard across most browsers. 112 | - If you try to copy unsupported data types, they may be silently dropped (e.g. Safari 13.1) or the call may throw an error (e.g. Chrome 83). In general, it is not possible to tell when data types are dropped. 113 | - In some current browsers, `read()` may only return a subset of supported data types, even if the clipboard contains more data types. There is no way to tell if there were more data types. 114 | 115 | ### `overwrite-globals` version 116 | 117 | If you want the library to overwrite the global clipboard API with its implementations, import `clipboard-polyfill/overwrite-globals`. This will turn the library from a [ponyfill](https://ponyfill.com/) into a proper polyfill, so you can write code as if the async clipboard API were already implemented in your browser: 118 | 119 | ```js 120 | import "clipboard-polyfill/overwrite-globals"; 121 | 122 | async function handler() { 123 | const item = new window.ClipboardItem({ 124 | "text/html": new Blob( 125 | ["Markup text. Paste me into a rich text editor."], 126 | { type: "text/html" } 127 | ), 128 | "text/plain": new Blob( 129 | ["Fallback markup text. Paste me into a rich text editor."], 130 | { type: "text/plain" } 131 | ), 132 | }); 133 | 134 | navigator.clipboard.write([item]); 135 | } 136 | 137 | window.addEventListener("DOMContentLoaded", function () { 138 | const button = document.body.appendChild(document.createElement("button")); 139 | button.textContent = "Copy"; 140 | button.addEventListener("click", handler); 141 | }); 142 | ``` 143 | 144 | This approach is not recommended, because it may break any other code that interacts with the clipboard API globals, and may be incompatible with future browser implementations. 145 | 146 | ### Flat-file version with `Promise` included 147 | 148 | If you need to grab a version that "just works", download [`clipboard-polyfill.window-var.promise.es5.js`](https://unpkg.com/clipboard-polyfill/dist/es5/window-var/clipboard-polyfill.window-var.promise.es5.js) and include it using a ` 152 | 153 | 159 | ``` 160 | 161 | ### Bundling / tree shaking / minification / CommonJS 162 | 163 | Thanks to the conveniences of the modern JS ecosystem, we do not provide tree shaken, minified, or CommonJS builds anymore. To get such builds without losing compatibility, pass `clipboard-polyfill` builds through `esbuild`. For example: 164 | 165 | ```shell 166 | mkdir temp && cd temp && npm install clipboard-polyfill esbuild 167 | 168 | # Minify the ES6 build: 169 | echo 'export * from "clipboard-polyfill";' | npx esbuild --format=esm --target=es6 --bundle --minify 170 | 171 | # Include just the `writeText()` export and minify: 172 | echo 'export { writeText } from "clipboard-polyfill";' | npx esbuild --format=esm --target=es6 --bundle --minify 173 | 174 | # Minify an ES5 build: 175 | cat node_modules/clipboard-polyfill/dist/es5/window-var/clipboard-polyfill.window-var.promise.es5.js | npx esbuild --format=esm --target=es5 --bundle --minify 176 | 177 | # Get a CommonJS build: 178 | echo 'export * from "clipboard-polyfill";' | npx esbuild --format=cjs --target=es6 --bundle 179 | ``` 180 | 181 | ## Why `clipboard-polyfill`? 182 | 183 | Browsers have implemented several clipboard APIs over time, and writing to the clipboard without [triggering bugs in various old and current browsers](https://github.com/lgarron/clipboard-polyfill/blob/master/experiment/Conclusions.md) is fairly tricky. In every browser that supports copying to the clipboard in some way, `clipboard-polyfill` attempts to act as close as possible to the async clipboard API. (See above for disclaimers and limitations.) 184 | 185 | See [this presentation](https://docs.google.com/presentation/d/1Ix2rYi67hbZoIQsd85kspkUPLi8Q-PZopy_AtfafHW0) for for a longer history of clipboard access on the web. 186 | 187 | ## Compatibility 188 | 189 | - ☑️: Browser has native async clipboard support. 190 | - ✅: `clipboard-polyfill` adds support. 191 | - ❌: Support is not possible. 192 | - **Bold browser names** indicate the latest functionality changes for stable versions of modern browsers. 193 | 194 | Write support by earliest browser version: 195 | 196 | | Browser | `writeText()` | `write()` (HTML) | `write()` (other formats) | 197 | | ------------------------------------------- | ------------- | ---------------- | ---------------------------------- | 198 | | **Safari 13.1** | ☑️ | ☑️ | ☑️ (`image/uri-list`, `image/png`) | 199 | | **Chrome 86**ᵃ / **Edge 86** | ☑️ | ☑️ | ☑️ (`image/png`) | 200 | | Chrome 76ᵃ / Edge 79 | ☑️ | ✅ | ☑️ (`image/png`) | 201 | | Chrome 66ᵃ / **Firefox 63** | ☑️ | ✅ | ❌ | 202 | | Safari 10 / Chrome 42ᵃ / Edgeᵈ / Firefox 41 | ✅ | ✅ᵇ | ❌ | 203 | | IE 9 | ✅ᶜ | ❌ | ❌ | 204 | 205 | Read support: 206 | 207 | | Browser | `readText()` | `read()` (HTML) | `read()` (other formats) | 208 | | ----------------------------------------------------------------------------------- | ------------ | --------------- | ---------------------------------- | 209 | | **Safari 13.1** | ☑️ | ☑️ | ☑️ (`image/uri-list`, `image/png`) | 210 | | **Chrome [76](https://web.dev/image-support-for-async-clipboard/)** ᵃ / **Edge 79** | ☑️ | ❌ | ☑️ (`image/png`) | 211 | | Chrome [66](https://developers.google.com/web/updates/2018/03/clipboardapi)ᵃ | ☑️ | ❌ | ❌ | 212 | | IE 9 | ✅ᶜ | ❌ | ❌ | 213 | | **Firefox** | ❌ | ❌ | ❌ | 214 | 215 | - ᵃ Also includes versions of Edge, Opera, Brave, Vivaldi, etc. based on the corresponding version of Chrome. 216 | - ᵇ HTML did not work properly on mobile Safari in the first few releases of version 10. 217 | - ᶜ In Internet Explorer, you will need to polyfill `window.Promise` if you want the library to work. 218 | - ᵈ In older versions of Edge (Spartan): 219 | - It may not be possible to tell if a copy operation succeeded ([Edge Bug #14110451](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14110451/), [Edge Bug #14080262](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14080262/)). `clipboard-polyfill` will always report success in this case. 220 | - Only the _last_ data type you specify is copied to the clipboard ([Edge Bug #14080506](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14080506/)). Consider placing the most important data type last in the object that you pass to the `ClipboardItem` constructor. 221 | - The `text/html` data type is not written using the expected `CF_HTML` format. `clipboard-polyfill` does _not_ try to work around this, since 1) it would require fragile browser version sniffing, 2) users of Edge are not generally stuck on version < 17, and 3) the failure mode for other browsers would be that invalid clipboard HTML is copied. ([Edge Bug #14372529](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14372529/), [#73](https://github.com/lgarron/clipboard-polyfill/issues/73)) 222 | 223 | `clipboard-polyfill` uses a variety of heuristics to work around compatibility bugs. Please [let us know](https://github.com/lgarron/clipboard-polyfill/issues/new) if you are running into compatibility issues with any of the browsers listed above. 224 | 225 | ## History 226 | 227 | ### Browser history 228 | 229 | | Browser | First version supporting
`navigator.clipboard.writeText(...)` | Release Date | 230 | | ------- | ------------------------------------------------------------- | ------------ | 231 | | Chrome | 66+ | April 2018 | 232 | | Firefox | 53+ | October 2018 | 233 | | Edge | 79+ (first Chromium-based release) | January 2020 | 234 | | Safari | 13.1+ | March 2020 | 235 | 236 | ### Project history 237 | 238 | This project dates from a time when clipboard access in JS was barely becoming possible, and [ergonomic clipboard API efforts were stalling](https://lists.w3.org/Archives/Public/public-webapps/2015JulSep/0235.html). (See [this presentation](https://docs.google.com/presentation/d/1Ix2rYi67hbZoIQsd85kspkUPLi8Q-PZopy_AtfafHW0/) for a bit more context.) Fortunately, [an ergonomic API with the same functionality](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard) is now available in all modern browsers since 2020: 239 | 240 | - 2015: Browsers [start supporting](https://caniuse.com/mdn-api_document_execcommand_copy) the [defunct](https://w3c.github.io/editing/docs/execCommand/) `document.execCommand("copy")` call (with [many, many issues](./experiment/Conclusions.md)). 241 | - 2015: Started this project as `clipboard.js` (half a year before @zenorocha picked [the same name](https://github.com/zenorocha/clipboard.js) 😛). 242 | - 2016: Renewed discussions about an async clipboard API (e.g. [proposal doc](https://docs.google.com/document/d/1QI5rKJSiYeD9ekP2NyCYJuOnivduC9-tqEOn-GsCGS4/edit#), [`crbug.com/593475`](https://bugs.chromium.org/p/chromium/issues/detail?id=593475)). 243 | - 2017: Renamed this project to `clipboard-polyfill` to reflect a `v2` API overhaul aligned with the draft spec. 244 | - 2018: Browsers [start supporting](https://caniuse.com/mdn-api_clipboard_writetext) `navigator.clipboard.writeText()`. 245 | - 2020: Browsers [start supporting](https://caniuse.com/mdn-api_clipboard_write) `navigator.clipboard.write()` (including `text/html` support). 246 | 247 | Thanks to Gary Kacmarcik, Hallvord Steen, and others for helping to bring the [async clipboard API](https://w3c.github.io/clipboard-apis/) to life! 248 | 249 | ## This is way too complicated! 250 | 251 | If you only need to copy text in modern browsers, consider using `navigator.clipboard.writeText()` directly: 252 | 253 | If you need copy text in older browsers as well, you could also try [this gist](https://gist.github.com/lgarron/d1dee380f4ed9d825ca7) for a simple hacky solution. 254 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "files": { 4 | "includes": ["**", "!.temp", "!dist"] 5 | }, 6 | "formatter": { 7 | "indentStyle": "space", 8 | "indentWidth": 2 9 | }, 10 | "linter": { 11 | "rules": { 12 | "suspicious": { 13 | "noExplicitAny": "off" 14 | }, 15 | "performance": { 16 | "noDelete": "off" 17 | }, 18 | "correctness": { 19 | "noInnerDeclarations": "off" 20 | }, 21 | "complexity": { 22 | "useLiteralKeys": "off" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "clipboard-polyfill", 6 | "devDependencies": { 7 | "@biomejs/biome": "^2.0.0-beta.3", 8 | "@cubing/deploy": "^0.2.2", 9 | "@types/bun": "^1.1.0", 10 | "barely-a-dev-server": "^0.7.1", 11 | "esbuild": "^0.25.0", 12 | "typescript": "^5.4.5", 13 | }, 14 | }, 15 | }, 16 | "packages": { 17 | "@biomejs/biome": ["@biomejs/biome@2.0.0-beta.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.0-beta.3", "@biomejs/cli-darwin-x64": "2.0.0-beta.3", "@biomejs/cli-linux-arm64": "2.0.0-beta.3", "@biomejs/cli-linux-arm64-musl": "2.0.0-beta.3", "@biomejs/cli-linux-x64": "2.0.0-beta.3", "@biomejs/cli-linux-x64-musl": "2.0.0-beta.3", "@biomejs/cli-win32-arm64": "2.0.0-beta.3", "@biomejs/cli-win32-x64": "2.0.0-beta.3" }, "bin": { "biome": "bin/biome" } }, "sha512-KKvtP0iapIpmOpoljEITOcG7IPOn2HVUJxZOn4ULJbUsOlPEJcrKI0BwKZ/E1PeflxjaSfcKUxHhdB7bjBBPvg=="], 18 | 19 | "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.0.0-beta.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UYeevewqg3YOnwGEPA4m6+lQQY+YiMeev4GufnurOxodl1QoYCdMosaBhlGlISBa/HwBDw9JQ10yjzsBF9GlTQ=="], 20 | 21 | "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.0.0-beta.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-jANiFMTFq2Bgw1cySc6I4R1tZlkkY1Go5mdAQDXZIwAr+45TGKLzxiOKJdWTN8GGQ0KzOwJxjHZ5vV6vx1JxsA=="], 22 | 23 | "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.0.0-beta.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-KW4NLOW6n4YPEa4bVo4GPYBJpeIuFfxVd2tsyM+xjuGk8zmo4dkswPUxKN/qW/mh/98ISvdKsusKoSZ6M3kpLg=="], 24 | 25 | "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.0.0-beta.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mOQ48zNYZ6A+0eQUyZlPwuLJdEPkUdaoOLnbL1Y9GbeQfvvQYGwohDTIfcHk7yUxSImB1pPyLpiguam0jymeOg=="], 26 | 27 | "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.0.0-beta.3", "", { "os": "linux", "cpu": "x64" }, "sha512-oYtyC7oNpQ8+2uq3hEZGhYv/EtIuwMsv7OcL5cnFzrrzVEtaB6jh5jOlYkA+agovQRVv/bgWfP4FO6yIHskG8w=="], 28 | 29 | "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.0.0-beta.3", "", { "os": "linux", "cpu": "x64" }, "sha512-170/P0qpudpwZCP0U/lfU4cxjpDZwTUi/lSYKfAPyVQfnRX8P+1j+3hv4voaREBC1nenPdsIJ4daWb6BB7W2jQ=="], 30 | 31 | "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.0.0-beta.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-+ed97qATUxuAJ+rrnwCqzJ+Xhn082NV5P00GyRyLDGXGxJYMvpWhSUJWW06VABDc9UftoowOop3xK43YfcJStA=="], 32 | 33 | "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.0-beta.3", "", { "os": "win32", "cpu": "x64" }, "sha512-bKenWRtAayTZ+gaSXR7ySTtdC9LkDm1tiYMXNSCTA0D633hT2vNxdjsJ1uYQmehVvdqUkXJ+nrTLtrjWuS1mKA=="], 34 | 35 | "@cubing/deploy": ["@cubing/deploy@0.2.2", "", { "dependencies": { "printable-shell-command": "0.1.0-pre3", "typescript": "^5.8.3" }, "bin": { "deploy": "dist/lib/@cubing/deploy/main.js" } }, "sha512-+AWXz2fRpDkX//CF0VmfisrpbQwI/yX/j1rmGH3xi9qDucDyfgXfcnVxuTQTY1vehfMYCRGb5X/+V5SI4Jn4wA=="], 36 | 37 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="], 38 | 39 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.0", "", { "os": "android", "cpu": "arm" }, "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g=="], 40 | 41 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.0", "", { "os": "android", "cpu": "arm64" }, "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g=="], 42 | 43 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.0", "", { "os": "android", "cpu": "x64" }, "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg=="], 44 | 45 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw=="], 46 | 47 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg=="], 48 | 49 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w=="], 50 | 51 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A=="], 52 | 53 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg=="], 54 | 55 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg=="], 56 | 57 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg=="], 58 | 59 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw=="], 60 | 61 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ=="], 62 | 63 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw=="], 64 | 65 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.0", "", { "os": "linux", "cpu": "none" }, "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA=="], 66 | 67 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA=="], 68 | 69 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw=="], 70 | 71 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.0", "", { "os": "none", "cpu": "arm64" }, "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw=="], 72 | 73 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.0", "", { "os": "none", "cpu": "x64" }, "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA=="], 74 | 75 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw=="], 76 | 77 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg=="], 78 | 79 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg=="], 80 | 81 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw=="], 82 | 83 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA=="], 84 | 85 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="], 86 | 87 | "@types/bun": ["@types/bun@1.1.0", "", { "dependencies": { "bun-types": "1.1.0" } }, "sha512-QGK0yU4jh0OK1A7DyhPkQuKjHQCC5jSJa3dpWIEhHv/rPfb6zLfdArc4/uUUZBMTcjilsafRXnPWO+1owb572Q=="], 88 | 89 | "@types/node": ["@types/node@22.15.23", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-7Ec1zaFPF4RJ0eXu1YT/xgiebqwqoJz8rYPDi/O2BcZ++Wpt0Kq9cl0eg6NN6bYbPnR67ZLo7St5Q3UK0SnARw=="], 90 | 91 | "@types/ws": ["@types/ws@8.5.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A=="], 92 | 93 | "barely-a-dev-server": ["barely-a-dev-server@0.7.1", "", { "peerDependencies": { "esbuild": "^0.25.0" } }, "sha512-RiDzA8fH1a4iqchsbq/5Mrt8ScfJm/33RV+GUzNWPvQiVGNJRQFCC3e68L8PXsVw5gKKL5sVAnA/20XIMAY32Q=="], 94 | 95 | "bun-types": ["bun-types@1.1.0", "", { "dependencies": { "@types/node": "~20.11.3", "@types/ws": "~8.5.10" } }, "sha512-GhMDD7TosdJzQPGUOcQD5PZshvXVxDfwGAZs2dq+eSaPsRn3iUCzvpFlsg7Q51bXVzLAUs+FWHlnmpgZ5UggIg=="], 96 | 97 | "esbuild": ["esbuild@0.25.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.0", "@esbuild/android-arm": "0.25.0", "@esbuild/android-arm64": "0.25.0", "@esbuild/android-x64": "0.25.0", "@esbuild/darwin-arm64": "0.25.0", "@esbuild/darwin-x64": "0.25.0", "@esbuild/freebsd-arm64": "0.25.0", "@esbuild/freebsd-x64": "0.25.0", "@esbuild/linux-arm": "0.25.0", "@esbuild/linux-arm64": "0.25.0", "@esbuild/linux-ia32": "0.25.0", "@esbuild/linux-loong64": "0.25.0", "@esbuild/linux-mips64el": "0.25.0", "@esbuild/linux-ppc64": "0.25.0", "@esbuild/linux-riscv64": "0.25.0", "@esbuild/linux-s390x": "0.25.0", "@esbuild/linux-x64": "0.25.0", "@esbuild/netbsd-arm64": "0.25.0", "@esbuild/netbsd-x64": "0.25.0", "@esbuild/openbsd-arm64": "0.25.0", "@esbuild/openbsd-x64": "0.25.0", "@esbuild/sunos-x64": "0.25.0", "@esbuild/win32-arm64": "0.25.0", "@esbuild/win32-ia32": "0.25.0", "@esbuild/win32-x64": "0.25.0" }, "bin": "bin/esbuild" }, "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw=="], 98 | 99 | "printable-shell-command": ["printable-shell-command@0.1.0-pre3", "", { "optionalDependencies": { "@types/bun": "^1.2.11", "@types/node": "^22.15.3" } }, "sha512-8/fSZ3piSi3o5gjJjbb7lK1hIz0lxMkWKpKl4nus3AUzS0bzosL48xTEtshy9TCMofw5GhT1dfCQNbY0bRwrng=="], 100 | 101 | "typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], 102 | 103 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 104 | 105 | "@cubing/deploy/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 106 | 107 | "@types/ws/@types/node": ["@types/node@20.11.30", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw=="], 108 | 109 | "bun-types/@types/node": ["@types/node@20.11.30", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw=="], 110 | 111 | "printable-shell-command/@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="], 112 | 113 | "@types/ws/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 114 | 115 | "bun-types/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 116 | 117 | "printable-shell-command/@types/bun/bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="], 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /clipboard-polyfill-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /experiment/Conclusions.md: -------------------------------------------------------------------------------- 1 | # Copying 2 | 3 | 4 | # Test results 5 | 6 | Platforms tested: 7 | - Chrome 61.0.3163.100 (macOS 10.13.0) 8 | - Safari 11.0 (macOS 10.13) 9 | - Safari 11.0 (iOS 11.0 on an iPhone SE) 10 | - Edge 15.15063 (Windows 10.0 in a VirtualBox VM) 11 | - Firefox 54.0 (macOS 10.13) 12 | 13 | | | Chrome 61 | Safari 11 (macOS) | Safari 11 (iOS) | Edge 15 | Firefox 54 | 14 | |---|---|---|---|---|---| 15 | |`supported` always returns true †|✅|✅|✅|✅|✅| 16 | |`enabled` **without** selection returns true †|❌|❌|❌|❌|✅| 17 | |`exec` works **without** selection †|✅|⚠️¹|⚠️¹|✅|✅| 18 | |`enabled` **with** selection returns true †|✅|✅|✅|✅|✅| 19 | |`exec` works **with** selection †|✅|✅|✅|✅|✅| 20 | |`exec` fails outside user gesture |✅|✅|✅|✅|✅| 21 | |`setData()` in listener works|✅|✅|❌ ²|✅|✅| 22 | |`getData()` in listener shows if `setData()` worked|✅|✅|⚠️ ²|❌ ³|✅| 23 | |Copies all types set with `setData()`|✅|✅|✅|❌ ⁴|✅| 24 | |`exec` reports success correctly|✅|✅|⚠️ ²|❌ ⁵|✅| 25 | |`contenteditable` does not break document selection|❌|❌|❌|✅|✅| 26 | |`user-select: none` does not break document selection|✅(Cr 64)|❌|❌|✅(Edge 16)|✅ (FF 57)| 27 | |Can construct `new DataTransfer()`|✅|❌|❌|❌|❌| 28 | |Writes `CF_HTML` on Windows|✅|N/A|N/A|❌⁶|✅| 29 | 30 | † Here, we are only specifically interested in the case where the handler is called directly in response to a user gesture. I didn't test for behaviour when there is no user gesture. 31 | 32 | - ¹ `document.execCommand("copy")` triggers a successul copy action, but listeners for the document's `copy` event aren't fired. [WebKit Bug #177715](https://bugs.webkit.org/show_bug.cgi?id=156529) 33 | - ² [WebKit Bug #177715](https://bugs.webkit.org/show_bug.cgi?id=177715) 34 | - ³ [Edge Bug #14110451](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14110451/) 35 | - ⁴ [Edge Bug #14080506](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14080506/) 36 | - ⁵ [Edge Bug #14080262](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14080262/) 37 | - ⁶ [Edge Bug #14372529](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14372529/), [GitHub issue #73](https://github.com/lgarron/clipboard-polyfill/issues/73) 38 | 39 | ## `supported` always returns true 40 | 41 | In all browsers, `document.queryCommandSupported("copy")` always returns true. 42 | 43 | ## `enabled` **without** selection returns true (see issue 1 below) 44 | 45 | When nothing on the page is selected, `document.queryCommandEnabled("copy")` returns true in Firefox, but not any other browsers. 46 | 47 | ## `exec` fires listener **without** selection 48 | 49 | On all platforms, `document.execCommand("copy")` always works (triggers a copy) during a user gesture, regardless of whether anything on the page is selected. However, on Safari listeners registered using `document.addEventListener("copy")` don't fire (and therefore don't have an opportunity to set the data on the clipboard) if there is no selection. 50 | 51 | ## `enabled` **with** selection returns true 52 | 53 | On all browsers, `document.queryCommandEnabled("copy")` returns true during a user gesture if some part of the page is selected (doesn't matter which part; can be the entire body or a single element). The selection may be made using Javascript during the user gesture handler itself. 54 | 55 | ## `exec` fires listener **with** selection 56 | 57 | On all platforms, `document.execCommand("copy")` works during a user gesture, regardless of whether anything on the page is selected. Listeners registered with `document.addEventListener("copy")` fire. 58 | 59 | ## `enabled` returns false outside user gesture 60 | 61 | In all browsers, `document.execCommand("copy")` fails when there is no user gesture, and returns `false`. 62 | 63 | ## `setData()` works in listener (see issue 3 and issue 4 below) 64 | 65 | This means that the following works: 66 | 67 | document.addEventListener("copy", function(e) { 68 | e.clipboardData.setData("text/plain", "plain text") 69 | e.preventDefault(); 70 | }); 71 | 72 | On iOS, the `setData` call doesn't work – it actually empties the clipboard (at least for that data type). This is supposedly fixed in WebKit as of September 19, 2017: 73 | Fortunately, it is possible to detect Safari's behaviour (when the value is not empty), because the following returns `""` even after the `setData()` call: 74 | 75 | e.clipboardData.getData("text/plain", "plain text") 76 | 77 | ## `getData()` in listener shows if `setData()` worked 78 | 79 | In Edge, `setData()` works inside the copy listener, but `getData()` never reports the data that was set, and returns the empty string instead. 80 | 81 | Note that on iOS Safari, `getData()` also returns the empty string, but since `setData()` doesn't work, this is the correct return value (and can be used to detect if setting a non-empty string succeeded). 82 | 83 | ## Copies all types set with `setData()` (see issue 2 below) 84 | 85 | This means that the following listeners put both plain text and HTML on the clipboard: 86 | 87 | document.addEventListener("copy", function(e) { 88 | e.clipboardData.setData("text/plain", "plain text") 89 | e.clipboardData.setData("text/html", "markup text") 90 | e.preventDefault(); 91 | }); 92 | 93 | document.addEventListener("copy", function(e) { 94 | e.clipboardData.setData("text/html", "markup text") 95 | e.clipboardData.setData("text/plain", "plain text") 96 | e.preventDefault(); 97 | }); 98 | 99 | Edge only places the *last* provided data type on the clipboard. 100 | 101 | ## `exec` reports success correctly (see issue 5 below) 102 | 103 | Most platforms correctly report if `document.execCommand("copy")` successfully copied something to the clipboard. 104 | 105 | On iOS, `document.execCommand("copy")` also returns `true` when `event.clipboardData.setData()` clears the clipboard. In this case, the clipboard is set to empty, but the return value is arguably correct once we account for the relevant bug. 106 | 107 | Edge, however, *always* returns `false`. Even when the copy attempt succeeds. 108 | 109 | ## `contenteditable` does not break document seleciton 110 | 111 | Consider the following code: 112 | 113 | var sel = document.getSelection(); 114 | var range = document.createRange(); 115 | range.selectNodeContents(document.body); 116 | sel.addRange(range); 117 | 118 | This fails in Chrome and Safari if the last content in the DOM is the following: 119 | 120 |
121 | 122 | ## `user-select: none` does not break document selection 123 | 124 | In Safari, the DOM selection API does not allow Javascript to select parts of the DOM that are not selectable by the user due to `-webkit-user-select: none`. 125 | 126 | Reported at https://github.com/lgarron/clipboard-polyfill/issues/75 127 | 128 | As a workaround for Safari, it is possible to select an element nested unside an unselectable element that explicitly uses `-webkit-user-select: text` to enable selection. It seems that we should be able to rely on this, since it [is the specified behaviour](https://drafts.csswg.org/css-ui-4/#valdef-user-select-none). However, note that other browsers (e.g. [Firefox <21](https://developer.mozilla.org/en-US/docs/Web/CSS/user-select)) have implemented behaviour that doesn't match the spec. 129 | 130 | ## Writes `CF_HTML` on Windows 131 | 132 | In Edge 16 and earlier, `clipboardData.setData("text/html", data)` does not properly write HTML to the clipbard in the Windows `CF_HTML` clipboard format. 133 | 134 | Reported at https://github.com/lgarron/clipboard-polyfill/issues/73 135 | 136 | ## Can construct `new DataTransfer()` (see issue 6 below) 137 | 138 | The new asynchronous clipboard API takes a `DataTranfer` input. However, the only browser in which you can call the `DataTransfer` constructor is Chrome. (The constructor was made publicly callable specifically for the async clipboard API.) 139 | 140 | 141 | # Strategy 142 | 143 | Firstly: 144 | 145 | - **Issue 1**: `queryCommandEnabled()` doesn't tell us when copying will work. 146 | - Workaround: Don't consult `queryCommandEnabled()`; just try `execCommand()` every time. 147 | 148 | All platforms except iOS can share the same default implementation. However: 149 | 150 | - **Issue 2**: Edge will only put the last provided data type on the clipboard. 151 | - Workaround: File a bug against Edge. (Started: [Edge Bug #14080506](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14080506/)) 152 | - Document that the caller should add the most important data type to the copy data last. 153 | 154 | TODO: Add "Safari doesn't trigger listener without selection" issue. 155 | 156 | iOS Safari requires the trickiest fallback: 157 | 158 | - **Issue 3**: For iOS Safari, it seems we can't attach data types in the listener at all. 159 | - Workaround: Detect the issue, and fall back to copying the `text/plain` data type with a different mechanism. 160 | - Document that callers should always provide a `text/plain` data type if they want copying to work on iOS. 161 | 162 | The logic will be as follows: 163 | - Is there a `text/plain` data type in the input? 164 | - No? ⇒ No fallback. Clipboard will likely end up blank on iOS. (Consider warning the user if they don't provide a value for the `text/plain` data type.) 165 | - Yes? ⇒ Check `setData()` against `getData()` for the `text/plain` data type. Do they match? 166 | - Yes? ⇒ Do nothing. (This will result in a blank clipboard when the copied string is empty.) 167 | - No? ⇒ Fall back. 168 | 169 | We fall back creating a temporary DOM element, assigning the `text/plain` value to it using `textContent`, selecting it using Javascript, and triggering `execCommand("copy")` again. (The repeated copy command appears to work on iOS.) We will place the element within a shadow root in order to prevent outside formatting (e.g. page background color) from affecting the text, and use `white-space: pre-wrap` to preserve newlines and whitespace. However: 170 | 171 | - **Issue 4**: On iOS, the copied text will still have the explicit formatting style of the default text in shadow root (issue 3) 172 | - Workaround: none. 173 | - Document this. 174 | 175 | The Windows problem looks a bit annoying. 176 | 177 | On Windows, we perform the copy, but we will always get back `false`. 178 | 179 | - **Issue 5**: On Windows, `execCommand("copy")` always returns false. 180 | - Workaround 0: Report this bug to Edge, and hope they fix it. (Started: [Edge Bug #14080262](https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/14080262/)) 181 | - Workaround 1: Pass on the return value blindly, and document that Windows has a bug. 182 | - Workaround 2: Never check the return value of `execCommand("copy")` 183 | - Workaround 3: Detect Edge using a different mechanism (e.g. UA sniffing), and ignore the return value only when we think we're in Edge. 184 | 185 | We also need to add some more polyfilling than we might like: 186 | 187 | - **Issue 6**: The caller can't construct a `DataTransfer` to pass to the polyfill on any platform except Chrome. 188 | - Workaround: Provide an object with a sufficiently ergonomic subset of the interface of `DataTransfer` that the caller can use. (We can swap out the implementation with `DataTransfer` once platforms allow calling the constructor directly.) 189 | 190 | - **Issue 7**: Internet Explorer did its own thing. 191 | - Workaround: [old implementation](https://github.com/lgarron/clipboard-polyfill/blob/94c9df4aa2ce1ca1b08280bf36923b65648d9f72/clipboard-polyfill#L167) using `window.clipboardData`. Requires a `Promise` polyfill. :-/ 192 | -------------------------------------------------------------------------------- /experiment/experiment.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: #f5f5f5; 4 | font-family: "Roboto", Helvetica, Tahoma, sans-serif; 5 | } 6 | 7 | .header { 8 | font-size: 20px; 9 | color: rgb(66, 66, 66); 10 | letter-spacing: 0.02em; 11 | display: flex; 12 | align-items: center; 13 | height: 64px; 14 | padding: 0 16px 0 40px; 15 | 16 | font-family: "Roboto", Helvetica, Tahoma, sans-serif; 17 | } 18 | 19 | .ribbon { 20 | width: 100%; 21 | height: 40vh; 22 | background-color: #3f51b5; 23 | } 24 | 25 | .content { 26 | padding: 80px 56px; 27 | font-size: 14px; 28 | background-color: #ffffff; 29 | color: #424242; 30 | margin: calc(-35vh + 16px) auto 80px; 31 | max-width: 939px; 32 | width: calc(66.66667% - 138px); 33 | box-shadow: 34 | 0 4px 5px 0 rgba(0, 0, 0, 0.14), 35 | 0 1px 10px 0 rgba(0, 0, 0, 0.12), 36 | 0 2px 4px -1px rgba(0, 0, 0, 0.2); 37 | border-radius: 2px; 38 | 39 | line-height: 24px; 40 | } 41 | 42 | @media (max-width: 839px) { 43 | .content { 44 | width: calc(100% - 88px); 45 | padding: 40px 28px; 46 | } 47 | 48 | .header { 49 | height: 56px; 50 | } 51 | } 52 | 53 | .crumbs { 54 | color: #9e9e9e; 55 | line-height: 20px; 56 | } 57 | 58 | h3 { 59 | font-weight: normal; 60 | margin: 48px 0px 24px; 61 | font-size: 34px; 62 | /* For a closer match of the original template: */ 63 | font-family: "Roboto", Helvetica, Tahoma, sans-serif; 64 | line-height: 40px; 65 | } 66 | 67 | p { 68 | margin: 0 0 16px; 69 | } 70 | -------------------------------------------------------------------------------- /experiment/experiment.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | clipboard-polyfill experiments 6 | 7 | 8 | 9 | 16 | 17 | 18 | 19 |
20 | clipboard-polyfill experiments 21 |
22 |
23 |
24 | 25 |

Test

26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 67 | 68 |
69 |
70 | 71 |
72 | 73 | 74 | -------------------------------------------------------------------------------- /experiment/experiment.ts: -------------------------------------------------------------------------------- 1 | /** biome-ignore-all lint/style/noNonNullAssertion: We assume that the `.clipboardData` field is present. */ 2 | 3 | // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/setData 4 | // biome-ignore lint/correctness/noUnusedVariables: TODO 5 | interface DataTransfer { 6 | setData: (key: string, value: any) => void; 7 | getData: (key: string) => any; 8 | } 9 | 10 | interface IEWindowClipboardData { 11 | setData: (key: string, value: string) => void; 12 | getData: (key: string) => string; 13 | } 14 | 15 | interface IEWindow extends Window { 16 | clipboardData: IEWindowClipboardData; 17 | } 18 | 19 | export class Test { 20 | results: { [key: string]: any } = {}; 21 | run() { 22 | this.results["queryCommandSupported"] = 23 | document.queryCommandSupported("copy"); 24 | 25 | this.results["pre_start.enabled"] = document.queryCommandEnabled("copy"); 26 | try { 27 | this.setup(); 28 | } catch (e) { 29 | console.error(e); 30 | } 31 | this.results["post_setup.enabled"] = document.queryCommandEnabled("copy"); 32 | 33 | var listener = this.copyListener.bind(this); 34 | document.addEventListener("copy", listener); 35 | try { 36 | var success = document.execCommand("copy"); 37 | this.results["exec.success"] = success; 38 | } finally { 39 | document.removeEventListener("copy", listener); 40 | } 41 | 42 | this.results["pre_teardown.enabled"] = document.queryCommandEnabled("copy"); 43 | try { 44 | this.teardown(); 45 | } catch (e) { 46 | console.error(e); 47 | } 48 | this.results["post_teardown.enabled"] = 49 | document.queryCommandEnabled("copy"); 50 | 51 | console.log(JSON.stringify(this.results, null, " ")); 52 | } 53 | setup() {} 54 | copyListener(_e: ClipboardEvent) {} 55 | teardown() {} 56 | select(e: Element) { 57 | var sel = document.getSelection()!; 58 | sel.removeAllRanges(); 59 | var range = document.createRange(); 60 | range.selectNodeContents(e); 61 | sel.addRange(range); 62 | } 63 | clearSelection() { 64 | var sel = document.getSelection()!; 65 | sel.removeAllRanges(); 66 | } 67 | } 68 | 69 | export namespace Test { 70 | export class Plain extends Test { 71 | copyListener(e: ClipboardEvent) { 72 | console.log("copyListener", "Plain"); 73 | this.results["pre_setData.text_plain"] = 74 | e.clipboardData!.getData("text/plain"); 75 | e.clipboardData!.setData("text/plain", "Plain"); 76 | this.results["post_setData.text_plain"] = 77 | e.clipboardData!.getData("text/plain"); 78 | e.preventDefault(); 79 | } 80 | } 81 | 82 | export class PlainHTML extends Test { 83 | copyListener(e: ClipboardEvent) { 84 | console.log("copyListener", "PlainHTML"); 85 | this.results["pre_setData.text_plain"] = 86 | e.clipboardData!.getData("text/plain"); 87 | e.clipboardData!.setData("text/html", "PlainHTML markup"); 88 | this.results["post_setData.text_plain"] = 89 | e.clipboardData!.getData("text/plain"); 90 | e.preventDefault(); 91 | } 92 | } 93 | 94 | export class PlainBoth extends Test { 95 | copyListener(e: ClipboardEvent) { 96 | console.log("copyListener", "PlainBoth"); 97 | this.results["pre_setData.text_plain"] = 98 | e.clipboardData!.getData("text/plain"); 99 | e.clipboardData!.setData("text/plain", "PlainBoth no markup"); 100 | e.clipboardData!.setData("text/html", "PlainBoth markup"); 101 | this.results["post_setData.text_plain"] = 102 | e.clipboardData!.getData("text/plain"); 103 | e.preventDefault(); 104 | } 105 | } 106 | 107 | export class PlainBothHTMLFirst extends Test { 108 | copyListener(e: ClipboardEvent) { 109 | console.log("copyListener", "PlainBothHTMLFirst"); 110 | this.results["pre_setData.text_plain"] = 111 | e.clipboardData!.getData("text/plain"); 112 | e.clipboardData!.setData("text/html", "PlainBothHTMLFirst markup"); 113 | e.clipboardData!.setData("text/plain", "PlainBothHTMLFirst no markup"); 114 | this.results["post_setData.text_plain"] = 115 | e.clipboardData!.getData("text/plain"); 116 | e.preventDefault(); 117 | } 118 | } 119 | 120 | export class SelectBody extends Test { 121 | setup() { 122 | this.select(document.body); 123 | } 124 | copyListener(e: ClipboardEvent) { 125 | console.log("copyListener", "SelectBody"); 126 | this.results["pre_setData.text_plain"] = 127 | e.clipboardData!.getData("text/plain"); 128 | e.clipboardData!.setData("text/plain", "SelectBody"); 129 | this.results["post_setData.text_plain"] = 130 | e.clipboardData!.getData("text/plain"); 131 | e.preventDefault(); 132 | } 133 | teardown() { 134 | // TODO: Restore selection? 135 | this.clearSelection(); 136 | } 137 | } 138 | 139 | export class SelectTempElem extends Test { 140 | private tempElem: Element; 141 | setup() { 142 | this.tempElem = document.createElement("pre"); 143 | this.tempElem.textContent = "SelectTempElem"; 144 | document.body.appendChild(this.tempElem); 145 | this.select(this.tempElem); 146 | } 147 | copyListener(e: ClipboardEvent) { 148 | console.log("copyListener", "SelectTempElem"); 149 | this.results["pre_setData.text_plain"] = 150 | e.clipboardData!.getData("text/plain"); 151 | e.clipboardData!.setData("text/plain", "SelectTempElem"); 152 | this.results["post_setData.text_plain"] = 153 | e.clipboardData!.getData("text/plain"); 154 | e.preventDefault(); 155 | } 156 | teardown() { 157 | this.clearSelection(); 158 | document.body.removeChild(this.tempElem); 159 | } 160 | } 161 | 162 | export class SelectTempElemUserSelectNone extends Test { 163 | private tempElem: HTMLElement; 164 | setup() { 165 | this.tempElem = document.createElement("pre"); 166 | this.tempElem.style["user-select"] = "none"; 167 | this.tempElem.style["-webkit-user-select"] = "none"; 168 | this.tempElem.style["-moz-user-select"] = "none"; 169 | this.tempElem.style["-ms-user-select"] = "none"; 170 | this.tempElem.textContent = "SelectTempElemUserSelectNone"; 171 | document.body.appendChild(this.tempElem); 172 | this.select(this.tempElem); 173 | } 174 | copyListener(e: ClipboardEvent) { 175 | console.log("copyListener", "SelectTempElemUserSelectNone"); 176 | this.results["pre_setData.text_plain"] = 177 | e.clipboardData!.getData("text/plain"); 178 | e.clipboardData!.setData("text/plain", "SelectTempElemUserSelectNone"); 179 | this.results["post_setData.text_plain"] = 180 | e.clipboardData!.getData("text/plain"); 181 | e.preventDefault(); 182 | } 183 | teardown() { 184 | this.clearSelection(); 185 | document.body.removeChild(this.tempElem); 186 | } 187 | } 188 | 189 | export class SelectTempElemUserSelectNoneNested extends Test { 190 | private tempElem: HTMLElement; 191 | private tempElem2: HTMLElement; 192 | setup() { 193 | this.tempElem2 = document.createElement("pre"); 194 | this.tempElem2.style["user-select"] = "text"; 195 | this.tempElem2.style["-webkit-user-select"] = "text"; 196 | this.tempElem2.style["-moz-user-select"] = "text"; 197 | this.tempElem2.style["-ms-user-select"] = "text"; 198 | this.tempElem2.textContent = "SelectTempElemUserSelectNoneNested"; 199 | 200 | this.tempElem = document.createElement("pre"); 201 | this.tempElem.style["user-select"] = "none"; 202 | this.tempElem.style["-webkit-user-select"] = "none"; 203 | this.tempElem.style["-moz-user-select"] = "none"; 204 | this.tempElem.style["-ms-user-select"] = "none"; 205 | this.tempElem.appendChild(this.tempElem2); 206 | document.body.appendChild(this.tempElem); 207 | 208 | this.select(this.tempElem2); 209 | } 210 | copyListener(e: ClipboardEvent) { 211 | console.log("copyListener", "SelectTempElemUserSelectNoneNested"); 212 | this.results["pre_setData.text_plain"] = 213 | e.clipboardData!.getData("text/plain"); 214 | e.clipboardData!.setData( 215 | "text/plain", 216 | "SelectTempElemUserSelectNoneNested", 217 | ); 218 | this.results["post_setData.text_plain"] = 219 | e.clipboardData!.getData("text/plain"); 220 | e.preventDefault(); 221 | } 222 | teardown() { 223 | this.clearSelection(); 224 | document.body.removeChild(this.tempElem); 225 | } 226 | } 227 | 228 | export class CopyTempElem extends Test { 229 | private tempElem: Element; 230 | setup() { 231 | this.tempElem = document.createElement("div"); 232 | var shadowRoot = this.tempElem.attachShadow({ mode: "open" }); 233 | document.body.appendChild(this.tempElem); 234 | 235 | var span = document.createElement("span"); 236 | span.textContent = `CopyTempElem\n${new Date()}`; 237 | span.style.whiteSpace = "pre-wrap"; 238 | shadowRoot.appendChild(span); 239 | this.select(span); 240 | } 241 | copyListener(e: ClipboardEvent) { 242 | console.log("copyListener", "CopyTempElem"); 243 | this.results["no_setData.text_plain"] = 244 | e.clipboardData!.getData("text/plain"); 245 | } 246 | teardown() { 247 | this.clearSelection(); 248 | document.body.removeChild(this.tempElem); 249 | } 250 | } 251 | 252 | export class WindowClipboardData extends Test { 253 | private tempElem: Element; 254 | run() { 255 | this.results["start.enabled"] = document.queryCommandEnabled("copy"); 256 | (window as any as IEWindow).clipboardData!.setData( 257 | "Text", 258 | "WindowClipboardData", 259 | ); 260 | this.results["end.enabled"] = document.queryCommandEnabled("copy"); 261 | } 262 | } 263 | 264 | export class DataTransferConstructor extends Test { 265 | setup() { 266 | try { 267 | var dt = new DataTransfer(); 268 | dt.setData("text/plain", "plain text"); 269 | dt.setData("text/html", "markup text"); 270 | this.results["getData.text_plain"] = dt.getData("text/plain"); 271 | this.results["getData.text_html"] = dt.getData("text/html"); 272 | } catch (e) { 273 | console.error(e); 274 | } 275 | } 276 | } 277 | } 278 | 279 | // TODO: Try `event.clipboardData.items.add()` in listener. 280 | 281 | // TODO: MutationObserver test. 282 | -------------------------------------------------------------------------------- /overwrite-globals/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/es5/overwrite-globals/clipboard-polyfill.overwrite-globals.es5.js", 3 | "types": "../dist/types/entries/es5/overwrite-globals.d.ts" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clipboard-polyfill", 3 | "version": "4.1.1", 4 | "description": "A polyfill for the asynchronous clipboard API", 5 | "author": "Lucas Garron (https://garron.net/)", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/lgarron/clipboard-polyfill/issues" 9 | }, 10 | "type": "module", 11 | "main": "./dist/es6/clipboard-polyfill.es6.js", 12 | "module": "./dist/es6/clipboard-polyfill.es6.js", 13 | "types": "./dist/types/entries/es6/clipboard-polyfill.es6.d.ts", 14 | "exports": { 15 | ".": { 16 | "types": "./dist/types/entries/es6/clipboard-polyfill.es6.d.ts", 17 | "import": "./dist/es6/clipboard-polyfill.es6.js", 18 | "default": "./dist/es6/clipboard-polyfill.es6.js" 19 | }, 20 | "./overwrite-globals": { 21 | "types": "./dist/types/entries/es5/overwrite-globals.d.ts", 22 | "import": "./dist/es5/overwrite-globals/clipboard-polyfill.overwrite-globals.es5.js", 23 | "default": "./dist/es5/overwrite-globals/clipboard-polyfill.overwrite-globals.es5.js" 24 | } 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/lgarron/clipboard-polyfill" 29 | }, 30 | "keywords": [ 31 | "clipboard", 32 | "HTML5", 33 | "copy", 34 | "copying", 35 | "cut", 36 | "paste", 37 | "execCommand", 38 | "setData", 39 | "getData", 40 | "polyfill" 41 | ], 42 | "files": [ 43 | "/dist", 44 | "/overwrite-globals", 45 | "README.md" 46 | ], 47 | "devDependencies": { 48 | "@biomejs/biome": "^2.0.0-beta.3", 49 | "@cubing/deploy": "^0.2.2", 50 | "@types/bun": "^1.1.0", 51 | "barely-a-dev-server": "^0.7.1", 52 | "esbuild": "^0.25.0", 53 | "typescript": "^5.4.5" 54 | }, 55 | "scripts": { 56 | "prepublishOnly": "make prepublishOnly" 57 | }, 58 | "@cubing/deploy": { 59 | "$schema": "./node_modules/@cubing/deploy/config-schema.json", 60 | "https://garron.net/code/clipboard-polyfill": { 61 | "fromLocalDir": "./dist/demo/" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /script/build-demo.ts: -------------------------------------------------------------------------------- 1 | import { barelyServe } from "barely-a-dev-server"; 2 | 3 | await barelyServe({ 4 | dev: false, 5 | entryRoot: "./src/demo", 6 | outDir: "./dist/demo", 7 | esbuildOptions: { 8 | minify: false, 9 | target: "es5", 10 | splitting: false, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /script/build.ts: -------------------------------------------------------------------------------- 1 | import { build } from "esbuild"; 2 | 3 | await build({ 4 | entryPoints: [ 5 | "./src/clipboard-polyfill/entries/es6/clipboard-polyfill.es6.ts", 6 | ], 7 | format: "esm", 8 | target: "es6", 9 | bundle: true, 10 | sourcemap: true, 11 | outdir: "./dist/es6/", 12 | }); 13 | 14 | async function buildES5(src, entriestem) { 15 | await build({ 16 | entryPoints: [src], 17 | target: "es5", 18 | bundle: true, 19 | sourcemap: true, 20 | banner: { js: '"use strict";' }, 21 | outfile: `${entriestem}.es5.js`, 22 | }); 23 | } 24 | 25 | await buildES5( 26 | "src/clipboard-polyfill/entries/es5/window-var.ts", 27 | "dist/es5/window-var/clipboard-polyfill.window-var", 28 | ); 29 | await buildES5( 30 | "src/clipboard-polyfill/entries/es5/window-var.promise.ts", 31 | "dist/es5/window-var/clipboard-polyfill.window-var.promise", 32 | ); 33 | await buildES5( 34 | "src/clipboard-polyfill/entries/es5/overwrite-globals.ts", 35 | "dist/es5/overwrite-globals/clipboard-polyfill.overwrite-globals", 36 | ); 37 | await buildES5( 38 | "src/clipboard-polyfill/entries/es5/overwrite-globals.promise.ts", 39 | "dist/es5/overwrite-globals/clipboard-polyfill.overwrite-globals.promise", 40 | ); 41 | -------------------------------------------------------------------------------- /script/dev.ts: -------------------------------------------------------------------------------- 1 | import { barelyServe } from "barely-a-dev-server"; 2 | 3 | await barelyServe({ 4 | entryRoot: "./src/demo", 5 | outDir: "./.temp/dev", 6 | esbuildOptions: { 7 | target: "es5", 8 | splitting: false, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /script/mock-test.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | function runMockText { 6 | BASENAME="${1}" 7 | npx esbuild \ 8 | --format=esm --target=es2020 \ 9 | --bundle --external:node:assert \ 10 | --supported:top-level-await=true \ 11 | --outfile="./dist/mock-test/${BASENAME}.js" \ 12 | "./src/mock-test/${BASENAME}.ts" 13 | 14 | node "./dist/mock-test/${BASENAME}.js" 15 | } 16 | 17 | runMockText modern-writeText 18 | runMockText missing-Promise 19 | -------------------------------------------------------------------------------- /script/test-bun.ts: -------------------------------------------------------------------------------- 1 | import { basename } from "node:path"; 2 | import { Glob, spawn } from "bun"; 3 | 4 | const glob = new Glob("**/*.test.ts"); 5 | 6 | console.log("Running bun test files individually"); 7 | 8 | // Scans the current working directory and each of its sub-directories recursively 9 | for await (const file of glob.scan(".")) { 10 | if (basename(file) === "bun-test-cannot-run-all-tests.test.ts") { 11 | continue; 12 | } 13 | console.log(`Running: bun test "${file}"`); 14 | 15 | if ((await spawn(["bun", "test", file], {}).exited) !== 0) { 16 | throw new Error(`Bun test failed for file: ${file}`); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/ClipboardItem/ClipboardItemPolyfill.ts: -------------------------------------------------------------------------------- 1 | import { promiseConstructor } from "../builtins/builtin-globals"; 2 | import { stringToBlob } from "./convert"; 3 | import type { 4 | ClipboardItemConstructor, 5 | ClipboardItemDataType, 6 | ClipboardItemInterface, 7 | ClipboardItemOptions, 8 | } from "./spec"; 9 | 10 | function ClipboardItemPolyfillImpl( 11 | // TODO: The spec specifies values as `ClipboardItemData`, but 12 | // implementations (e.g. Chrome 83) seem to assume `ClipboardItemDataType` 13 | // values. https://github.com/w3c/clipboard-apis/pull/126 14 | items: { [type: string]: ClipboardItemDataType }, 15 | options?: ClipboardItemOptions, 16 | ): ClipboardItemInterface { 17 | var types = Object.keys(items); 18 | var _items: { [type: string]: Blob } = {}; 19 | 20 | for (var type in items) { 21 | var item = items[type]; 22 | if (typeof item === "string") { 23 | _items[type] = stringToBlob(type, item); 24 | } else { 25 | _items[type] = item; 26 | } 27 | } 28 | // The explicit default for `presentationStyle` is "unspecified": 29 | // https://www.w3.org/TR/clipboard-apis/#clipboard-interface 30 | var presentationStyle = options?.presentationStyle ?? "unspecified"; 31 | 32 | function getType(type: string): Promise { 33 | return promiseConstructor.resolve(_items[type]); 34 | } 35 | return { 36 | types: types, 37 | presentationStyle: presentationStyle, 38 | getType: getType, 39 | }; 40 | } 41 | 42 | export var ClipboardItemPolyfill: ClipboardItemConstructor = 43 | ClipboardItemPolyfillImpl as any as ClipboardItemConstructor; 44 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/ClipboardItem/check.ts: -------------------------------------------------------------------------------- 1 | import type { ClipboardItemInterface } from "./spec"; 2 | 3 | export function hasItemWithType( 4 | clipboardItems: ClipboardItemInterface[], 5 | typeName: string, 6 | ): boolean { 7 | for (var i = 0; i < clipboardItems.length; i++) { 8 | var item = clipboardItems[i]; 9 | if (item.types.indexOf(typeName) !== -1) { 10 | return true; 11 | } 12 | } 13 | return false; 14 | } 15 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/ClipboardItem/convert.ts: -------------------------------------------------------------------------------- 1 | import { 2 | originalWindowClipboardItem, 3 | promiseConstructor, 4 | } from "../builtins/builtin-globals"; 5 | import { promiseRecordMap } from "../promise/promise-compat"; 6 | import { ClipboardItemPolyfill } from "./ClipboardItemPolyfill"; 7 | import { TEXT_PLAIN } from "./data-types"; 8 | import type { ClipboardItemInterface, ClipboardItemOptions } from "./spec"; 9 | 10 | export function stringToBlob(type: string, str: string): Blob { 11 | return new Blob([str], { 12 | type, 13 | }); 14 | } 15 | 16 | export function blobToString(blob: Blob): Promise { 17 | return new promiseConstructor((resolve, reject) => { 18 | var fileReader = new FileReader(); 19 | fileReader.addEventListener("load", () => { 20 | var result = fileReader.result; 21 | if (typeof result === "string") { 22 | resolve(result); 23 | } else { 24 | reject("could not convert blob to string"); 25 | } 26 | }); 27 | fileReader.readAsText(blob); 28 | }); 29 | } 30 | 31 | export function clipboardItemToGlobalClipboardItem( 32 | clipboardItem: ClipboardItemInterface, 33 | ): Promise { 34 | // Note that we use `Blob` instead of `ClipboardItemDataType`. This is because 35 | // Chrome 83 can only accept `Blob` (not `string`). The return value of 36 | // `getType()` is already `Blob` per the spec, so this is simple for us. 37 | return promiseRecordMap(clipboardItem.types, (type: string) => { 38 | return clipboardItem.getType(type); 39 | }).then((items: Record) => { 40 | return new promiseConstructor((resolve, reject) => { 41 | var options: ClipboardItemOptions = {}; 42 | if (clipboardItem.presentationStyle) { 43 | options.presentationStyle = clipboardItem.presentationStyle; 44 | } 45 | if (originalWindowClipboardItem) { 46 | resolve(new originalWindowClipboardItem(items, options)); 47 | } else { 48 | reject("window.ClipboardItem is not defined"); 49 | } 50 | }); 51 | }); 52 | } 53 | 54 | export function textToClipboardItem(text: string): ClipboardItemInterface { 55 | var items: { [type: string]: Blob } = {}; 56 | items[TEXT_PLAIN] = stringToBlob(text, TEXT_PLAIN); 57 | return new ClipboardItemPolyfill(items); 58 | } 59 | 60 | export function getTypeAsString( 61 | clipboardItem: ClipboardItemInterface, 62 | type: string, 63 | ): Promise { 64 | return clipboardItem.getType(type).then((text: Blob) => { 65 | return blobToString(text); 66 | }); 67 | } 68 | 69 | export interface StringItem { 70 | [type: string]: string; 71 | } 72 | 73 | export function toStringItem( 74 | data: ClipboardItemInterface, 75 | ): Promise { 76 | return promiseRecordMap(data.types, (type: string) => 77 | getTypeAsString(data, type), 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/ClipboardItem/data-types.ts: -------------------------------------------------------------------------------- 1 | export var TEXT_PLAIN = "text/plain"; 2 | export var TEXT_HTML = "text/html"; 3 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/ClipboardItem/spec.ts: -------------------------------------------------------------------------------- 1 | // This should be a `.d.ts` file, but we need to make it `.ts` (or Rollup won't include it in the output). 2 | 3 | // This file is a representation of the Clipboard Interface from the async 4 | // clipboard API spec. We match the order and spacing of the spec as much as 5 | // possible, for easy comparison. 6 | // https://www.w3.org/TR/clipboard-apis/#clipboard-interface 7 | 8 | // The spec specifies some non-optional fields, but initial browser implementations of the async clipboard API 9 | // may not have them. We don't rely on their existence in this library, and we 10 | // mark them as optional with [optional here, non-optional in spec]. 11 | 12 | export type ClipboardItems = ClipboardItemInterface[]; 13 | 14 | export interface ClipboardWithoutEventTarget { 15 | read(): Promise; 16 | readText(): Promise; 17 | write(data: ClipboardItems): Promise; 18 | writeText(data: string): Promise; 19 | } 20 | 21 | export interface ClipboardEventTarget 22 | extends EventTarget, 23 | ClipboardWithoutEventTarget {} 24 | 25 | export type ClipboardItemDataType = string | Blob; 26 | export type ClipboardItemData = Promise; 27 | 28 | export type ClipboardItemDelayedCallback = () => ClipboardItemDelayedCallback; 29 | 30 | // We can't specify the constructor (or static methods) inside the main 31 | // interface definition, so we specify it separately. See 32 | // https://www.typescriptlang.org/docs/handbook/interfaces.html#difference-between-the-static-and-instance-sides-of-classes 33 | export interface ClipboardItemConstructor { 34 | // Note: some implementations (e.g. Chrome 83) only acceps Blob item values, 35 | // and throw an exception if you try to pass any strings. 36 | new ( 37 | // TODO: The spec specifies values as `ClipboardItemData`, but 38 | // implementations (e.g. Chrome 83) seem to assume `ClipboardItemDataType` 39 | // values. https://github.com/w3c/clipboard-apis/pull/126 40 | items: { [type: string]: ClipboardItemDataType }, 41 | options?: ClipboardItemOptions, 42 | ): ClipboardItemInterface; 43 | 44 | createDelayed?( 45 | // [optional here, non-optional in spec] 46 | items: { [type: string]: () => ClipboardItemDelayedCallback }, 47 | options?: ClipboardItemOptions, 48 | ): ClipboardItemInterface; 49 | } 50 | 51 | // We name this `ClipboardItemInterface` instead of `ClipboardItem` because we 52 | // implement our polyfill from the library as `ClipboardItem`. 53 | export interface ClipboardItemInterface { 54 | // Safari 13.1 implements `presentationStyle`: 55 | // https://webkit.org/blog/10855/async-clipboard-api/ 56 | readonly presentationStyle?: PresentationStyle; // [optional here, non-optional in spec] 57 | readonly lastModified?: number; // [optional here, non-optional in spec] 58 | readonly delayed?: boolean; // [optional here, non-optional in spec] 59 | 60 | readonly types: ReadonlyArray; 61 | 62 | getType(type: string): Promise; 63 | } 64 | 65 | export type PresentationStyle = "unspecified" | "inline" | "attachment"; 66 | 67 | export interface ClipboardItemOptions { 68 | presentationStyle?: PresentationStyle; 69 | } 70 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/builtins/builtin-globals.ts: -------------------------------------------------------------------------------- 1 | // We cache the references so that callers can do the following without causing infinite recursion/bugs: 2 | // 3 | // import * as clipboard from "clipboard-polyfill"; 4 | // navigator.clipboard = clipboard; 5 | // 6 | // import { ClipboardItem } from "clipboard-polyfill"; 7 | // window.ClipboardItem = clipboard; 8 | // 9 | // Note that per the spec: 10 | // 11 | // - is *not* possible to overwrite `navigator.clipboard`. https://www.w3.org/TR/clipboard-apis/#navigator-interface 12 | // - it *may* be possible to overwrite `window.ClipboardItem`. 13 | // 14 | // Chrome 83 and Safari 13.1 match this. We save the original 15 | // `navigator.clipboard` anyhow, because 1) it doesn't cost more code (in fact, 16 | // it probably saves code), and 2) just in case an unknown/future implementation 17 | // allows overwriting `navigator.clipboard` like this. 18 | 19 | import type { 20 | ClipboardEventTarget, 21 | ClipboardItemConstructor, 22 | ClipboardItems, 23 | } from "../ClipboardItem/spec"; 24 | import type { PromiseConstructor } from "../promise/es6-promise"; 25 | import { getPromiseConstructor } from "./promise-constructor"; 26 | import { originalWindow } from "./window-globalThis"; 27 | 28 | var originalNavigator = 29 | typeof navigator === "undefined" ? undefined : navigator; 30 | var originalNavigatorClipboard: ClipboardEventTarget | undefined = 31 | originalNavigator?.clipboard as any; 32 | export var originalNavigatorClipboardRead: 33 | | (() => Promise) 34 | | undefined = originalNavigatorClipboard?.read?.bind( 35 | originalNavigatorClipboard, 36 | ); 37 | export var originalNavigatorClipboardReadText: 38 | | (() => Promise) 39 | | undefined = originalNavigatorClipboard?.readText?.bind( 40 | originalNavigatorClipboard, 41 | ); 42 | export var originalNavigatorClipboardWrite: 43 | | ((data: ClipboardItems) => Promise) 44 | | undefined = originalNavigatorClipboard?.write?.bind( 45 | originalNavigatorClipboard, 46 | ); 47 | export var originalNavigatorClipboardWriteText: 48 | | ((data: string) => Promise) 49 | | undefined = originalNavigatorClipboard?.writeText?.bind( 50 | originalNavigatorClipboard, 51 | ); 52 | 53 | // The spec specifies that this goes on `window`, not e.g. `globalThis`. It's not (currently) available in workers. 54 | export var originalWindowClipboardItem: ClipboardItemConstructor | undefined = 55 | originalWindow?.ClipboardItem; 56 | 57 | export var promiseConstructor: PromiseConstructor = getPromiseConstructor(); 58 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/builtins/promise-constructor.ts: -------------------------------------------------------------------------------- 1 | import type { PromiseConstructor } from "../promise/es6-promise"; 2 | import { originalGlobalThis, originalWindow } from "./window-globalThis"; 3 | 4 | var promiseConstructorImpl: PromiseConstructor | undefined = 5 | (originalWindow as { Promise?: PromiseConstructor } | undefined)?.Promise ?? 6 | originalGlobalThis?.Promise; 7 | 8 | // This must be called *before* `builtin-globals.ts` is imported, or it has no effect. 9 | export function setPromiseConstructor( 10 | newPromiseConstructorImpl: PromiseConstructor, 11 | ) { 12 | promiseConstructorImpl = newPromiseConstructorImpl; 13 | } 14 | 15 | export function getPromiseConstructor(): PromiseConstructor { 16 | if (!promiseConstructorImpl) { 17 | throw new Error( 18 | "No `Promise` implementation available for `clipboard-polyfill`. Consider using: https://github.com/lgarron/clipboard-polyfill#flat-file-version-with-promise-included", 19 | ); 20 | } 21 | return promiseConstructorImpl; 22 | } 23 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/builtins/window-globalThis.ts: -------------------------------------------------------------------------------- 1 | export var originalWindow = typeof window === "undefined" ? undefined : window; 2 | export var originalGlobalThis = 3 | typeof globalThis === "undefined" ? undefined : globalThis; 4 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/debug.ts: -------------------------------------------------------------------------------- 1 | /******** Debug Logging ********/ 2 | 3 | // tslint:disable-next-line: no-empty 4 | var debugLogImpl = (_s: string) => {}; 5 | 6 | export function debugLog(s: string) { 7 | debugLogImpl(s); 8 | } 9 | 10 | export function setDebugLog(logFn: (s: string) => void) { 11 | debugLogImpl = logFn; 12 | } 13 | 14 | /******** Warnings ********/ 15 | 16 | var showWarnings = true; 17 | 18 | export function suppressWarnings() { 19 | showWarnings = false; 20 | } 21 | 22 | export function shouldShowWarnings(): boolean { 23 | return showWarnings; 24 | } 25 | 26 | // Workaround for: 27 | // - IE9 (can't bind console functions directly), and 28 | // - Edge Issue #14495220 (referencing `console` without F12 Developer Tools can cause an exception) 29 | function warnOrLog() { 30 | // biome-ignore lint/style/noArguments: Intentional old-fashioned code. 31 | (console.warn || console.log).apply(console, arguments); 32 | } 33 | 34 | export var warn = warnOrLog.bind("[clipboard-polyfill]"); 35 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/entries/es5/overwrite-globals.promise.ts: -------------------------------------------------------------------------------- 1 | // Set the Promise polyfill before globals. 2 | import "../../promise/set-promise-polyfill-if-needed"; 3 | // Import `./globals` that the globals are cached before this runs. 4 | import "../../builtins/builtin-globals"; 5 | 6 | import { PromisePolyfillConstructor } from "../../promise/polyfill"; 7 | 8 | import "./overwrite-globals"; 9 | 10 | (window as any).Promise = PromisePolyfillConstructor; 11 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/entries/es5/overwrite-globals.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardItemPolyfill } from "../../ClipboardItem/ClipboardItemPolyfill"; 2 | import { read, write } from "../../implementations/blob"; 3 | import { readText, writeText } from "../../implementations/text"; 4 | 5 | // Create the `navigator.clipboard` object if it doesn't exist. 6 | if (!navigator.clipboard) { 7 | (navigator as any).clipboard = {}; 8 | } 9 | 10 | // Set/replace the implementations. 11 | navigator.clipboard.read = read as any; 12 | navigator.clipboard.readText = readText; 13 | navigator.clipboard.write = write; 14 | navigator.clipboard.writeText = writeText; 15 | 16 | // @ts-ignore 17 | window.ClipboardItem = ClipboardItemPolyfill; 18 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/entries/es5/window-var.promise.ts: -------------------------------------------------------------------------------- 1 | // Set the Promise polyfill before globals. 2 | import "../../promise/set-promise-polyfill-if-needed"; 3 | 4 | import type { PromiseConstructor } from "../../promise/es6-promise"; 5 | import { PromisePolyfillConstructor } from "../../promise/polyfill"; 6 | 7 | import "./window-var"; 8 | 9 | declare global { 10 | var PromisePolyfill: PromiseConstructor; 11 | } 12 | 13 | window.PromisePolyfill = PromisePolyfillConstructor; 14 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/entries/es5/window-var.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardItemPolyfill } from "../../ClipboardItem/ClipboardItemPolyfill"; 2 | import type { 3 | ClipboardItemConstructor, 4 | ClipboardWithoutEventTarget, 5 | } from "../../ClipboardItem/spec"; 6 | import { setDebugLog, suppressWarnings } from "../../debug"; 7 | import { read, write } from "../../implementations/blob"; 8 | import { readText, writeText } from "../../implementations/text"; 9 | 10 | declare global { 11 | var clipboard: ClipboardWithoutEventTarget & { 12 | ClipboardItem: ClipboardItemConstructor; 13 | setDebugLog: typeof setDebugLog; 14 | suppressWarnings: typeof suppressWarnings; 15 | }; 16 | } 17 | 18 | window.clipboard = { 19 | read: read, 20 | readText: readText, 21 | write: write, 22 | writeText: writeText, 23 | ClipboardItem: ClipboardItemPolyfill, 24 | setDebugLog: setDebugLog, 25 | suppressWarnings: suppressWarnings, 26 | }; 27 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/entries/es6/clipboard-polyfill.es6.ts: -------------------------------------------------------------------------------- 1 | export { ClipboardItemPolyfill as ClipboardItem } from "../../ClipboardItem/ClipboardItemPolyfill"; 2 | export type { 3 | ClipboardItemConstructor, 4 | ClipboardItemData, 5 | ClipboardItemDataType, 6 | ClipboardItemDelayedCallback, 7 | ClipboardItemInterface, 8 | ClipboardItemOptions, 9 | ClipboardItems, 10 | PresentationStyle, 11 | } from "../../ClipboardItem/spec"; 12 | export { setDebugLog, suppressWarnings } from "../../debug"; 13 | export { read, write } from "../../implementations/blob"; 14 | export { readText, writeText } from "../../implementations/text"; 15 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/blob.ts: -------------------------------------------------------------------------------- 1 | import { 2 | originalNavigatorClipboardRead, 3 | originalNavigatorClipboardWrite, 4 | originalWindowClipboardItem, 5 | promiseConstructor, 6 | } from "../builtins/builtin-globals"; 7 | import { hasItemWithType } from "../ClipboardItem/check"; 8 | import { 9 | clipboardItemToGlobalClipboardItem, 10 | type StringItem, 11 | textToClipboardItem, 12 | toStringItem, 13 | } from "../ClipboardItem/convert"; 14 | import { TEXT_HTML, TEXT_PLAIN } from "../ClipboardItem/data-types"; 15 | import type { 16 | ClipboardItemInterface, 17 | ClipboardItems, 18 | } from "../ClipboardItem/spec"; 19 | import { debugLog, shouldShowWarnings } from "../debug"; 20 | import { 21 | falsePromise, 22 | rejectThrownErrors, 23 | truePromiseFn, 24 | voidPromise, 25 | } from "../promise/promise-compat"; 26 | import { readText } from "./text"; 27 | import { writeFallback } from "./write-fallback"; 28 | 29 | export function write(data: ClipboardItemInterface[]): Promise { 30 | // Use the browser implementation if it exists. 31 | // TODO: detect `text/html`. 32 | return rejectThrownErrors((): Promise => { 33 | if (originalNavigatorClipboardWrite && originalWindowClipboardItem) { 34 | // TODO: This reference is a workaround for TypeScript inference. 35 | var originalNavigatorClipboardWriteCached = 36 | originalNavigatorClipboardWrite; 37 | debugLog("Using `navigator.clipboard.write()`."); 38 | return promiseConstructor 39 | .all(data.map(clipboardItemToGlobalClipboardItem)) 40 | .then( 41 | ( 42 | globalClipboardItems: ClipboardItemInterface[], 43 | ): Promise => { 44 | return originalNavigatorClipboardWriteCached(globalClipboardItems) 45 | .then(truePromiseFn) 46 | .catch((e: Error): Promise => { 47 | // Chrome 83 will throw a DOMException or NotAllowedError because it doesn't support e.g. `text/html`. 48 | // We want to fall back to the other strategies in a situation like this. 49 | // See https://github.com/w3c/clipboard-apis/issues/128 and https://github.com/w3c/clipboard-apis/issues/67 50 | if ( 51 | !hasItemWithType(data, TEXT_PLAIN) && 52 | !hasItemWithType(data, TEXT_HTML) 53 | ) { 54 | throw e; 55 | } 56 | return falsePromise; 57 | }); 58 | }, 59 | ); 60 | } 61 | return falsePromise; 62 | }).then((success: boolean) => { 63 | if (success) { 64 | return voidPromise; 65 | } 66 | 67 | var hasTextPlain = hasItemWithType(data, TEXT_PLAIN); 68 | if (shouldShowWarnings() && !hasTextPlain) { 69 | debugLog( 70 | "clipboard.write() was called without a " + 71 | "`text/plain` data type. On some platforms, this may result in an " + 72 | "empty clipboard. Call suppressWarnings() " + 73 | "to suppress this warning.", 74 | ); 75 | } 76 | 77 | return toStringItem(data[0]).then((stringItem: StringItem) => { 78 | if (!writeFallback(stringItem)) { 79 | throw new Error("write() failed"); 80 | } 81 | }); 82 | }); 83 | } 84 | 85 | export function read(): Promise { 86 | return rejectThrownErrors(() => { 87 | // Use the browser implementation if it exists. 88 | if (originalNavigatorClipboardRead) { 89 | debugLog("Using `navigator.clipboard.read()`."); 90 | return originalNavigatorClipboardRead(); 91 | } 92 | 93 | // Fallback to reading text only. 94 | return readText().then((text: string) => { 95 | return [textToClipboardItem(text)]; 96 | }); 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/text.blank-document.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | 3 | (globalThis as any).document = {}; 4 | 5 | test("writeText(…) failure in an unsupported browser", async () => { 6 | const { writeText } = await import("./text"); 7 | 8 | expect(async () => writeText("hello")).toThrowError( 9 | "document.addEventListener is not a function.", 10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/text.blank-environment.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, mock, test } from "bun:test"; 2 | import { setDebugLog } from "../debug"; 3 | 4 | const consoleLogMock = mock(console.log); 5 | setDebugLog(consoleLogMock); 6 | 7 | test("writeText(…) failure in a blank environmnent", async () => { 8 | const { writeText } = await import("./text"); 9 | 10 | expect(async () => writeText("hello")).toThrowError(ReferenceError); 11 | expect(consoleLogMock).toHaveBeenCalledTimes(0); 12 | }); 13 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/text.execCommand-fallback.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { 3 | createDebugLogConsoleMock, 4 | createDocumentMock, 5 | } from "../../test/mocks"; 6 | 7 | const debugLogConsoleMock = createDebugLogConsoleMock(); 8 | const { documentMock, eventMock } = createDocumentMock(); 9 | 10 | test("writeText(…) execCommand fallback in a modern browser", async () => { 11 | const { writeText } = await import("./text"); 12 | 13 | expect(() => writeText("hello execCommand fallback")).not.toThrow(); 14 | 15 | expect(debugLogConsoleMock.mock.calls).toEqual([ 16 | ["listener called"], 17 | ["regular execCopy worked"], 18 | ]); 19 | 20 | expect(documentMock.execCommand.mock.calls).toEqual([["copy"]]); 21 | 22 | expect(documentMock.addEventListener).toHaveBeenCalledTimes(1); 23 | expect(documentMock.removeEventListener).toHaveBeenCalledTimes(1); 24 | 25 | expect(eventMock.preventDefault).toHaveBeenCalledTimes(1); 26 | expect(eventMock.clipboardData.setData.mock.calls).toEqual([ 27 | ["text/plain", "hello execCommand fallback"], 28 | ]); 29 | 30 | expect(eventMock.clipboardData.getData.mock.calls).toEqual([["text/plain"]]); 31 | }); 32 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/text.modern-browser-async-api-disabled.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { 3 | createDebugLogConsoleMock, 4 | createDocumentMock, 5 | createWriteTextMock, 6 | } from "../../test/mocks"; 7 | 8 | const debugLogConsoleMock = createDebugLogConsoleMock(); 9 | const writeTextMock = createWriteTextMock(async () => { 10 | throw new Error("writeText(…) is disabled"); 11 | }); 12 | const { documentMock, eventMock } = createDocumentMock(); 13 | 14 | // Regression test for https://github.com/lgarron/clipboard-polyfill/issues/167 15 | test("writeText(…) success in a modern browser with the async API disabled", async () => { 16 | const { writeText } = await import("./text"); 17 | 18 | expect(() => 19 | writeText("hello modern browser with async API disabled"), 20 | ).not.toThrow(); 21 | 22 | expect(debugLogConsoleMock.mock.calls).toEqual([ 23 | ["Using `navigator.clipboard.writeText()`."], 24 | ["listener called"], 25 | ["regular execCopy worked"], 26 | ]); 27 | 28 | expect(writeTextMock.mock.calls).toEqual([ 29 | ["hello modern browser with async API disabled"], 30 | ]); 31 | 32 | expect(documentMock.execCommand.mock.calls).toEqual([["copy"]]); 33 | 34 | expect(documentMock.addEventListener).toHaveBeenCalledTimes(1); 35 | expect(documentMock.removeEventListener).toHaveBeenCalledTimes(1); 36 | 37 | expect(eventMock.preventDefault).toHaveBeenCalledTimes(1); 38 | expect(eventMock.clipboardData.setData.mock.calls).toEqual([ 39 | ["text/plain", "hello modern browser with async API disabled"], 40 | ]); 41 | 42 | expect(eventMock.clipboardData.getData.mock.calls).toEqual([["text/plain"]]); 43 | }); 44 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/text.modern-browser.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | import { 3 | createDebugLogConsoleMock, 4 | createDocumentMock, 5 | createWriteTextMock, 6 | } from "../../test/mocks"; 7 | 8 | const debugLogConsoleMock = createDebugLogConsoleMock(); 9 | const writeTextMock = createWriteTextMock(); 10 | const { documentMock } = createDocumentMock(); 11 | 12 | test("writeText(…) success in a modern browser", async () => { 13 | const { writeText } = await import("./text"); 14 | 15 | expect(() => writeText("hello modern browser")).not.toThrow(); 16 | 17 | expect(debugLogConsoleMock.mock.calls).toEqual([ 18 | ["Using `navigator.clipboard.writeText()`."], 19 | ]); 20 | 21 | expect(writeTextMock.mock.calls).toEqual([["hello modern browser"]]); 22 | 23 | expect(documentMock.execCommand).toHaveBeenCalledTimes(0); 24 | expect(documentMock.addEventListener).toHaveBeenCalledTimes(0); 25 | expect(documentMock.removeEventListener).toHaveBeenCalledTimes(0); 26 | }); 27 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/text.ts: -------------------------------------------------------------------------------- 1 | import { 2 | originalNavigatorClipboardReadText, 3 | originalNavigatorClipboardWriteText, 4 | promiseConstructor, 5 | } from "../builtins/builtin-globals"; 6 | import type { StringItem } from "../ClipboardItem/convert"; 7 | import { TEXT_PLAIN } from "../ClipboardItem/data-types"; 8 | import { debugLog } from "../debug"; 9 | import { rejectThrownErrors } from "../promise/promise-compat"; 10 | import { readTextIE, seemToBeInIE } from "../strategies/internet-explorer"; 11 | import { writeFallback } from "./write-fallback"; 12 | 13 | function stringToStringItem(s: string): StringItem { 14 | var stringItem: StringItem = {}; 15 | stringItem[TEXT_PLAIN] = s; 16 | return stringItem; 17 | } 18 | 19 | export function writeText(s: string): Promise { 20 | // Use the browser implementation if it exists. 21 | if (originalNavigatorClipboardWriteText) { 22 | debugLog("Using `navigator.clipboard.writeText()`."); 23 | return originalNavigatorClipboardWriteText(s).catch(() => 24 | writeTextStringFallbackPromise(s), 25 | ); 26 | } 27 | return writeTextStringFallbackPromise(s); 28 | } 29 | 30 | function writeTextStringFallbackPromise(s: string): Promise { 31 | return rejectThrownErrors(() => 32 | promiseConstructor.resolve(writeTextStringFallback(s)), 33 | ); 34 | } 35 | 36 | function writeTextStringFallback(s: string): void { 37 | if (!writeFallback(stringToStringItem(s))) { 38 | throw new Error("writeText() failed"); 39 | } 40 | } 41 | 42 | export function readText(): Promise { 43 | return rejectThrownErrors(() => { 44 | // Use the browser implementation if it exists. 45 | if (originalNavigatorClipboardReadText) { 46 | debugLog("Using `navigator.clipboard.readText()`."); 47 | return originalNavigatorClipboardReadText(); 48 | } 49 | 50 | // Fallback for IE. 51 | if (seemToBeInIE()) { 52 | var result = readTextIE(); 53 | return promiseConstructor.resolve(result); 54 | } 55 | 56 | throw new Error("Read is not supported in your browser."); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/implementations/write-fallback.ts: -------------------------------------------------------------------------------- 1 | import type { StringItem } from "../ClipboardItem/convert"; 2 | import { TEXT_PLAIN } from "../ClipboardItem/data-types"; 3 | import { debugLog } from "../debug"; 4 | import { 5 | copyTextUsingDOM, 6 | copyUsingTempElem, 7 | copyUsingTempSelection, 8 | execCopy, 9 | } from "../strategies/dom"; 10 | import { seemToBeInIE, writeTextIE } from "../strategies/internet-explorer"; 11 | 12 | // Note: the fallback order is carefully tuned for compatibility. It might seem 13 | // safe to move some of them around, but do not do so without testing all browsers. 14 | export function writeFallback(stringItem: StringItem): boolean { 15 | var hasTextPlain = TEXT_PLAIN in stringItem; 16 | 17 | // Internet Explorer 18 | if (seemToBeInIE()) { 19 | if (!hasTextPlain) { 20 | throw new Error("No `text/plain` value was specified."); 21 | } 22 | if (writeTextIE(stringItem[TEXT_PLAIN])) { 23 | return true; 24 | } 25 | throw new Error("Copying failed, possibly because the user rejected it."); 26 | } 27 | 28 | if (execCopy(stringItem)) { 29 | debugLog("regular execCopy worked"); 30 | return true; 31 | } 32 | 33 | // Success detection on Edge is not possible, due to bugs in all 4 34 | // detection mechanisms we could try to use. Assume success. 35 | if (navigator.userAgent.indexOf("Edge") > -1) { 36 | debugLog('UA "Edge" => assuming success'); 37 | return true; 38 | } 39 | 40 | // Fallback 1 for desktop Safari. 41 | if (copyUsingTempSelection(document.body, stringItem)) { 42 | debugLog("copyUsingTempSelection worked"); 43 | return true; 44 | } 45 | 46 | // Fallback 2 for desktop Safari. 47 | if (copyUsingTempElem(stringItem)) { 48 | debugLog("copyUsingTempElem worked"); 49 | return true; 50 | } 51 | 52 | // Fallback for iOS Safari. 53 | if (copyTextUsingDOM(stringItem[TEXT_PLAIN])) { 54 | debugLog("copyTextUsingDOM worked"); 55 | return true; 56 | } 57 | 58 | return false; 59 | } 60 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/promise/es6-promise.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript's ES6 definition for the `Promise` constructor, without the globally available variable. 2 | // Note that ES2015 is the same as ES6: https://262.ecma-international.org/6.0/ 3 | 4 | export interface PromiseConstructor { 5 | /** 6 | * A reference to the prototype. 7 | */ 8 | readonly prototype: Promise; 9 | 10 | /** 11 | * Creates a new Promise. 12 | * @param executor A callback used to initialize the promise. This callback is passed two arguments: 13 | * a resolve callback used to resolve the promise with a value or the result of another promise, 14 | * and a reject callback used to reject the promise with a provided reason or error. 15 | */ 16 | new ( 17 | executor: ( 18 | resolve: (value: T | PromiseLike) => void, 19 | reject: (reason?: any) => void, 20 | ) => void, 21 | ): Promise; 22 | 23 | /** 24 | * Creates a Promise that is resolved with an array of results when all of the provided Promises 25 | * resolve, or rejected when any Promise is rejected. 26 | * @param values An array of Promises. 27 | * @returns A new Promise. 28 | */ 29 | all( 30 | values: T, 31 | ): Promise<{ 32 | -readonly [P in keyof T]: Awaited; 33 | }>; 34 | 35 | // see: lib.es2015.iterable.d.ts 36 | // all(values: Iterable>): Promise[]>; 37 | 38 | /** 39 | * Creates a Promise that is resolved or rejected when any of the provided Promises are resolved 40 | * or rejected. 41 | * @param values An array of Promises. 42 | * @returns A new Promise. 43 | */ 44 | race( 45 | values: T, 46 | ): Promise>; 47 | 48 | // see: lib.es2015.iterable.d.ts 49 | // race(values: Iterable>): Promise>; 50 | 51 | /** 52 | * Creates a new rejected promise for the provided reason. 53 | * @param reason The reason the promise was rejected. 54 | * @returns A new rejected Promise. 55 | */ 56 | reject(reason?: any): Promise; 57 | 58 | /** 59 | * Creates a new resolved promise. 60 | * @returns A resolved promise. 61 | */ 62 | resolve(): Promise; 63 | /** 64 | * Creates a new resolved promise for the provided value. 65 | * @param value A promise. 66 | * @returns A promise whose internal state matches the provided promise. 67 | */ 68 | resolve(value: T): Promise>; 69 | /** 70 | * Creates a new resolved promise for the provided value. 71 | * @param value A promise. 72 | * @returns A promise whose internal state matches the provided promise. 73 | */ 74 | resolve(value: T | PromiseLike): Promise>; 75 | } 76 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/promise/polyfill.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore-all lint/complexity/useArrowFunction: Vendored code. 2 | 3 | import type { PromiseConstructor } from "./es6-promise"; 4 | 5 | /** 6 | * @this {PromisePolyfill} 7 | */ 8 | function finallyConstructor(callback) { 9 | var thisConstructor = this.constructor; 10 | return this.then( 11 | function (value) { 12 | return thisConstructor.resolve(callback()).then(() => { 13 | return value; 14 | }); 15 | }, 16 | function (reason) { 17 | return thisConstructor.resolve(callback()).then(() => { 18 | return thisConstructor.reject(reason); 19 | }); 20 | }, 21 | ); 22 | } 23 | 24 | function allSettled(arr) { 25 | // biome-ignore lint/complexity/noUselessThisAlias: Vendored code. 26 | var P = this; 27 | return new P(function (resolve, reject) { 28 | if (!(arr && typeof arr.length !== "undefined")) { 29 | return reject( 30 | new TypeError( 31 | typeof arr + 32 | " " + 33 | arr + 34 | " is not iterable(cannot read property Symbol(Symbol.iterator))", 35 | ), 36 | ); 37 | } 38 | var args = Array.prototype.slice.call(arr); 39 | if (args.length === 0) return resolve([]); 40 | var remaining = args.length; 41 | 42 | function res(i, val) { 43 | if (val && (typeof val === "object" || typeof val === "function")) { 44 | var then = val.then; 45 | if (typeof then === "function") { 46 | then.call( 47 | val, 48 | function (val) { 49 | res(i, val); 50 | }, 51 | function (e) { 52 | args[i] = { status: "rejected", reason: e }; 53 | if (--remaining === 0) { 54 | resolve(args); 55 | } 56 | }, 57 | ); 58 | return; 59 | } 60 | } 61 | args[i] = { status: "fulfilled", value: val }; 62 | if (--remaining === 0) { 63 | resolve(args); 64 | } 65 | } 66 | 67 | for (var i = 0; i < args.length; i++) { 68 | res(i, args[i]); 69 | } 70 | }); 71 | } 72 | 73 | // Store setTimeout reference so promise-polyfill will be unaffected by 74 | // other code modifying setTimeout (like sinon.useFakeTimers()) 75 | var setTimeoutFunc = setTimeout; 76 | 77 | function isArray(x) { 78 | return Boolean(x && typeof x.length !== "undefined"); 79 | } 80 | 81 | function noop() {} 82 | 83 | // Polyfill for Function.prototype.bind 84 | function bind(fn, thisArg) { 85 | return function () { 86 | // biome-ignore lint/style/noArguments: Vendored code. 87 | fn.apply(thisArg, arguments); 88 | }; 89 | } 90 | 91 | /** 92 | * @constructor 93 | * @param {Function} fn 94 | */ 95 | 96 | export function PromisePolyfill(fn) { 97 | if (!(this instanceof PromisePolyfill)) 98 | throw new TypeError("Promises must be constructed via new"); 99 | if (typeof fn !== "function") throw new TypeError("not a function"); 100 | /** @type {!number} */ 101 | this._state = 0; 102 | /** @type {!boolean} */ 103 | this._handled = false; 104 | /** @type {PromisePolyfill|undefined} */ 105 | this._value = undefined; 106 | /** @type {!Array} */ 107 | this._deferreds = []; 108 | 109 | doResolve(fn, this); 110 | } 111 | 112 | function handle(self, deferred) { 113 | while (self._state === 3) { 114 | self = self._value; 115 | } 116 | if (self._state === 0) { 117 | self._deferreds.push(deferred); 118 | return; 119 | } 120 | self._handled = true; 121 | PromisePolyfill._immediateFn(function () { 122 | var cb = self._state === 1 ? deferred.onFulfilled : deferred.onRejected; 123 | if (cb === null) { 124 | (self._state === 1 ? resolve : reject)(deferred.promise, self._value); 125 | return; 126 | } 127 | // biome-ignore lint/suspicious/noImplicitAnyLet: Vendored code. 128 | var ret; 129 | try { 130 | ret = cb(self._value); 131 | } catch (e) { 132 | reject(deferred.promise, e); 133 | return; 134 | } 135 | resolve(deferred.promise, ret); 136 | }); 137 | } 138 | 139 | function resolve(self, newValue) { 140 | try { 141 | // Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure 142 | if (newValue === self) 143 | throw new TypeError("A promise cannot be resolved with itself."); 144 | if ( 145 | newValue && 146 | (typeof newValue === "object" || typeof newValue === "function") 147 | ) { 148 | var then = newValue.then; 149 | if (newValue instanceof PromisePolyfill) { 150 | self._state = 3; 151 | self._value = newValue; 152 | finale(self); 153 | return; 154 | } else if (typeof then === "function") { 155 | doResolve(bind(then, newValue), self); 156 | return; 157 | } 158 | } 159 | self._state = 1; 160 | self._value = newValue; 161 | finale(self); 162 | } catch (e) { 163 | reject(self, e); 164 | } 165 | } 166 | 167 | function reject(self, newValue) { 168 | self._state = 2; 169 | self._value = newValue; 170 | finale(self); 171 | } 172 | 173 | function finale(self) { 174 | if (self._state === 2 && self._deferreds.length === 0) { 175 | PromisePolyfill._immediateFn(function () { 176 | if (!self._handled) { 177 | PromisePolyfill._unhandledRejectionFn(self._value); 178 | } 179 | }); 180 | } 181 | 182 | for (var i = 0, len = self._deferreds.length; i < len; i++) { 183 | handle(self, self._deferreds[i]); 184 | } 185 | self._deferreds = null; 186 | } 187 | 188 | /** 189 | * @constructor 190 | */ 191 | function Handler(onFulfilled, onRejected, promise) { 192 | this.onFulfilled = typeof onFulfilled === "function" ? onFulfilled : null; 193 | this.onRejected = typeof onRejected === "function" ? onRejected : null; 194 | this.promise = promise; 195 | } 196 | 197 | /** 198 | * Take a potentially misbehaving resolver function and make sure 199 | * onFulfilled and onRejected are only called once. 200 | * 201 | * Makes no guarantees about asynchrony. 202 | */ 203 | function doResolve(fn, self) { 204 | var done = false; 205 | try { 206 | fn( 207 | function (value) { 208 | if (done) return; 209 | done = true; 210 | resolve(self, value); 211 | }, 212 | function (reason) { 213 | if (done) return; 214 | done = true; 215 | reject(self, reason); 216 | }, 217 | ); 218 | } catch (ex) { 219 | if (done) return; 220 | done = true; 221 | reject(self, ex); 222 | } 223 | } 224 | 225 | PromisePolyfill.prototype["catch"] = function (onRejected) { 226 | return this.then(null, onRejected); 227 | }; 228 | 229 | // biome-ignore lint/suspicious/noThenProperty: This is specifically implementing the `Promise` API. 230 | PromisePolyfill.prototype.then = function (onFulfilled, onRejected) { 231 | // @ts-ignore 232 | var prom = new this.constructor(noop); 233 | 234 | handle(this, new Handler(onFulfilled, onRejected, prom)); 235 | return prom; 236 | }; 237 | 238 | PromisePolyfill.prototype["finally"] = finallyConstructor; 239 | 240 | PromisePolyfill.all = function (arr) { 241 | return new PromisePolyfill(function (resolve, reject) { 242 | if (!isArray(arr)) { 243 | return reject(new TypeError("Promise.all accepts an array")); 244 | } 245 | 246 | var args = Array.prototype.slice.call(arr); 247 | if (args.length === 0) return resolve([]); 248 | var remaining = args.length; 249 | 250 | function res(i, val) { 251 | try { 252 | if (val && (typeof val === "object" || typeof val === "function")) { 253 | var then = val.then; 254 | if (typeof then === "function") { 255 | then.call( 256 | val, 257 | function (val) { 258 | res(i, val); 259 | }, 260 | reject, 261 | ); 262 | return; 263 | } 264 | } 265 | args[i] = val; 266 | if (--remaining === 0) { 267 | resolve(args); 268 | } 269 | } catch (ex) { 270 | reject(ex); 271 | } 272 | } 273 | 274 | for (var i = 0; i < args.length; i++) { 275 | res(i, args[i]); 276 | } 277 | }); 278 | }; 279 | 280 | PromisePolyfill.allSettled = allSettled; 281 | 282 | PromisePolyfill.resolve = function (value) { 283 | if ( 284 | value && 285 | typeof value === "object" && 286 | value.constructor === PromisePolyfill 287 | ) { 288 | return value; 289 | } 290 | 291 | return new PromisePolyfill(function (resolve) { 292 | resolve(value); 293 | }); 294 | }; 295 | 296 | PromisePolyfill.reject = function (value) { 297 | return new PromisePolyfill(function (_resolve, reject) { 298 | reject(value); 299 | }); 300 | }; 301 | 302 | PromisePolyfill.race = function (arr) { 303 | return new PromisePolyfill(function (resolve, reject) { 304 | if (!isArray(arr)) { 305 | return reject(new TypeError("Promise.race accepts an array")); 306 | } 307 | 308 | for (var i = 0, len = arr.length; i < len; i++) { 309 | PromisePolyfill.resolve(arr[i]).then(resolve, reject); 310 | } 311 | }); 312 | }; 313 | 314 | // Use polyfill for setImmediate for performance gains 315 | PromisePolyfill._immediateFn = 316 | // @ts-ignore 317 | (typeof setImmediate === "function" && 318 | function (fn) { 319 | // @ts-ignore 320 | setImmediate(fn); 321 | }) || 322 | function (fn) { 323 | setTimeoutFunc(fn, 0); 324 | }; 325 | 326 | PromisePolyfill._unhandledRejectionFn = function _unhandledRejectionFn(err) { 327 | if (typeof console !== "undefined" && console) { 328 | console.warn("Possible Unhandled Promise Rejection:", err); // eslint-disable-line no-console 329 | } 330 | }; 331 | 332 | export var PromisePolyfillConstructor: PromiseConstructor = 333 | PromisePolyfill as any as PromiseConstructor; 334 | 335 | // Set the Promise polyfill before getting globals. 336 | import { setPromiseConstructor } from "../builtins/promise-constructor"; 337 | 338 | setPromiseConstructor(PromisePolyfillConstructor); 339 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/promise/promise-compat.ts: -------------------------------------------------------------------------------- 1 | import { promiseConstructor } from "../builtins/builtin-globals"; 2 | 3 | export function promiseRecordMap( 4 | keys: readonly string[], 5 | f: (key: string) => Promise, 6 | ): Promise> { 7 | var promiseList: Promise[] = []; 8 | for (var i = 0; i < keys.length; i++) { 9 | var key = keys[i]; 10 | promiseList.push(f(key)); 11 | } 12 | return promiseConstructor 13 | .all(promiseList) 14 | .then((vList: T[]): Record => { 15 | var dataOut: Record = {}; 16 | for (var i = 0; i < keys.length; i++) { 17 | dataOut[keys[i]] = vList[i]; 18 | } 19 | return dataOut; 20 | }); 21 | } 22 | 23 | export var voidPromise: Promise = promiseConstructor.resolve(); 24 | export var truePromiseFn: () => Promise = () => 25 | promiseConstructor.resolve(true); 26 | export var falsePromise: Promise = promiseConstructor.resolve(false); 27 | 28 | export function rejectThrownErrors(executor: () => Promise): Promise { 29 | return new promiseConstructor((resolve, reject) => { 30 | try { 31 | resolve(executor()); 32 | } catch (e) { 33 | reject(e); 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/promise/set-promise-polyfill-if-needed.ts: -------------------------------------------------------------------------------- 1 | import { setPromiseConstructor } from "../builtins/promise-constructor"; 2 | import { originalWindow } from "../builtins/window-globalThis"; 3 | import { PromisePolyfillConstructor } from "./polyfill"; 4 | 5 | originalWindow?.Promise || setPromiseConstructor(PromisePolyfillConstructor); 6 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/strategies/dom.ts: -------------------------------------------------------------------------------- 1 | import type { StringItem } from "../ClipboardItem/convert"; 2 | import { TEXT_PLAIN } from "../ClipboardItem/data-types"; 3 | import { debugLog } from "../debug"; 4 | 5 | /******** Implementations ********/ 6 | 7 | interface FallbackTracker { 8 | success: boolean; 9 | } 10 | 11 | function copyListener( 12 | tracker: FallbackTracker, 13 | data: StringItem, 14 | e: ClipboardEvent, 15 | ): void { 16 | debugLog("listener called"); 17 | tracker.success = true; 18 | // tslint:disable-next-line: forin 19 | for (var type in data) { 20 | var value = data[type]; 21 | 22 | // biome-ignore lint/style/noNonNullAssertion: We assume this field is present. 23 | var clipboardData = e.clipboardData!; 24 | clipboardData.setData(type, value); 25 | if (type === TEXT_PLAIN && clipboardData.getData(type) !== value) { 26 | debugLog("setting text/plain failed"); 27 | tracker.success = false; 28 | } 29 | } 30 | e.preventDefault(); 31 | } 32 | 33 | export function execCopy(data: StringItem): boolean { 34 | var tracker: FallbackTracker = { success: false }; 35 | var listener = copyListener.bind(this, tracker, data); 36 | 37 | document.addEventListener("copy", listener); 38 | try { 39 | // We ignore the return value, since FallbackTracker tells us whether the 40 | // listener was called. It seems that checking the return value here gives 41 | // us no extra information in any browser. 42 | document.execCommand("copy"); 43 | } finally { 44 | document.removeEventListener("copy", listener); 45 | } 46 | return tracker.success; 47 | } 48 | 49 | // Temporarily select a DOM element, so that `execCommand()` is not rejected. 50 | export function copyUsingTempSelection( 51 | e: HTMLElement, 52 | data: StringItem, 53 | ): boolean { 54 | selectionSet(e); 55 | var success = execCopy(data); 56 | selectionClear(); 57 | return success; 58 | } 59 | 60 | // Create a temporary DOM element to select, so that `execCommand()` is not 61 | // rejected. 62 | export function copyUsingTempElem(data: StringItem): boolean { 63 | var tempElem = document.createElement("div"); 64 | // Setting an individual property does not support `!important`, so we set the 65 | // whole style instead of just the `-webkit-user-select` property. 66 | tempElem.setAttribute("style", "-webkit-user-select: text !important"); 67 | // Place some text in the elem so that Safari has something to select. 68 | tempElem.textContent = "temporary element"; 69 | document.body.appendChild(tempElem); 70 | 71 | var success = copyUsingTempSelection(tempElem, data); 72 | 73 | document.body.removeChild(tempElem); 74 | return success; 75 | } 76 | 77 | // Uses shadow DOM. 78 | export function copyTextUsingDOM(str: string): boolean { 79 | debugLog("copyTextUsingDOM"); 80 | 81 | var tempElem = document.createElement("div"); 82 | // Setting an individual property does not support `!important`, so we set the 83 | // whole style instead of just the `-webkit-user-select` property. 84 | tempElem.setAttribute("style", "-webkit-user-select: text !important"); 85 | // Use shadow DOM if available. 86 | var spanParent: Node = tempElem; 87 | if (tempElem.attachShadow) { 88 | debugLog("Using shadow DOM."); 89 | spanParent = tempElem.attachShadow({ mode: "open" }); 90 | } 91 | 92 | var span = document.createElement("span"); 93 | span.innerText = str; 94 | 95 | spanParent.appendChild(span); 96 | document.body.appendChild(tempElem); 97 | selectionSet(span); 98 | 99 | var result = document.execCommand("copy"); 100 | 101 | selectionClear(); 102 | document.body.removeChild(tempElem); 103 | 104 | return result; 105 | } 106 | 107 | /******** Selection ********/ 108 | 109 | function selectionSet(elem: Element): void { 110 | var sel = document.getSelection(); 111 | if (sel) { 112 | var range = document.createRange(); 113 | range.selectNodeContents(elem); 114 | sel.removeAllRanges(); 115 | sel.addRange(range); 116 | } 117 | } 118 | 119 | function selectionClear(): void { 120 | var sel = document.getSelection(); 121 | if (sel) { 122 | sel.removeAllRanges(); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/clipboard-polyfill/strategies/internet-explorer.ts: -------------------------------------------------------------------------------- 1 | import { originalWindow } from "../builtins/window-globalThis"; 2 | import { debugLog } from "../debug"; 3 | 4 | interface IEWindow extends Window { 5 | clipboardData?: { 6 | setData: (key: string, value: string) => boolean; 7 | // Always results in a string: https://msdn.microsoft.com/en-us/library/ms536436(v=vs.85).aspx 8 | getData: (key: string) => string; 9 | }; 10 | } 11 | 12 | var ieWindow = originalWindow as IEWindow; 13 | 14 | export function seemToBeInIE(): boolean { 15 | return ( 16 | typeof ClipboardEvent === "undefined" && 17 | typeof ieWindow?.clipboardData !== "undefined" && 18 | typeof ieWindow?.clipboardData.setData !== "undefined" 19 | ); 20 | } 21 | 22 | export function writeTextIE(text: string): boolean { 23 | if (!ieWindow.clipboardData) { 24 | return false; 25 | } 26 | // IE supports text or URL, but not HTML: https://msdn.microsoft.com/en-us/library/ms536744(v=vs.85).aspx 27 | // TODO: Write URLs to `text/uri-list`? https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types 28 | var success = ieWindow.clipboardData.setData("Text", text); 29 | if (success) { 30 | debugLog("writeTextIE worked"); 31 | } 32 | return success; 33 | } 34 | 35 | // Returns "" if the read failed, e.g. because the user rejected the permission. 36 | export function readTextIE(): string { 37 | if (!ieWindow.clipboardData) { 38 | throw new Error("Cannot read IE clipboard Data "); 39 | } 40 | var text = ieWindow.clipboardData.getData("Text"); 41 | if (text === "") { 42 | throw new Error( 43 | "Empty clipboard or could not read plain text from clipboard", 44 | ); 45 | } 46 | return text; 47 | } 48 | -------------------------------------------------------------------------------- /src/demo/clipboard-polyfill-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/demo/demo.ts: -------------------------------------------------------------------------------- 1 | import "../clipboard-polyfill/entries/es5/window-var.promise"; 2 | -------------------------------------------------------------------------------- /src/demo/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: -apple-system, Roboto, Ubuntu, Tahoma, sans-serif; 3 | font-size: 1.25rem; 4 | padding: 2em; 5 | display: grid; 6 | justify-content: center; 7 | } 8 | 9 | body { 10 | width: 100%; 11 | max-width: 40em; 12 | margin: 0; 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | html { 17 | background: #000d; 18 | color: #eee; 19 | } 20 | 21 | a { 22 | color: #669df5; 23 | } 24 | 25 | a:visited { 26 | color: #af73d5; 27 | } 28 | } 29 | 30 | header { 31 | text-align: center; 32 | } 33 | 34 | td { 35 | padding: 0.5em; 36 | vertical-align: top; 37 | } 38 | 39 | table td:first-child { 40 | text-align: right; 41 | } 42 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | clipboard-polyfill 7 | 8 | 9 | 10 | 11 | 53 | 65 | 66 | 67 | 68 | 69 |
70 | 71 |

72 | clipboard-polyfill 73 |
74 | Demo/test page 75 |

76 |
77 | 78 | 79 | 80 | 83 | 86 | 94 | 95 | 96 | 99 | 102 | 115 | 116 | 117 | 120 | 127 | 142 | 143 | 144 | 147 | 150 | 163 | 164 | 165 | 168 | 171 | 179 | 180 | 181 | 184 | 187 | 188 |
81 | Plain text 82 | 84 | 85 | 87 | 88 | 93 |
97 | Markup 98 | 100 | 101 | 103 | 104 | 114 |
118 | DOM node 119 | 121 | 122 |
123 | This 125 | will be copied. 126 |
128 | 129 | 141 |
145 | Image (PNG) 146 | 148 | 149 | 151 | 152 |
153 | 162 |
166 | Paste text 167 | 169 | 170 | 172 | 173 | 178 |
182 | Paste area 183 | 185 |
186 |
189 | 190 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /src/demo/readme-examples/main.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/demo/readme-examples/main.ts: -------------------------------------------------------------------------------- 1 | import * as clipboard from "../../clipboard-polyfill/entries/es6/clipboard-polyfill.es6"; 2 | 3 | function handler() { 4 | clipboard.writeText("This text is plain.").then( 5 | () => { 6 | console.log("success!"); 7 | }, 8 | () => { 9 | console.log("error!"); 10 | }, 11 | ); 12 | } 13 | 14 | window.addEventListener("DOMContentLoaded", () => { 15 | var button = document.body.appendChild(document.createElement("button")); 16 | button.textContent = "Copy"; 17 | button.addEventListener("click", handler); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mock-test/missing-Promise.ts: -------------------------------------------------------------------------------- 1 | const importPromise = import( 2 | "../clipboard-polyfill/entries/es6/clipboard-polyfill.es6" 3 | ); 4 | 5 | const globalPromise = globalThis.Promise; 6 | // @ts-ignore: We're deleting something that's not normally meant to be deleted. 7 | delete globalThis.Promise; 8 | 9 | let caughtError: Error | undefined; 10 | try { 11 | await importPromise; 12 | } catch (e) { 13 | caughtError = e; 14 | } 15 | 16 | globalThis.Promise = globalPromise; 17 | 18 | import { equal } from "node:assert"; 19 | 20 | // TODO: Use `match` once `node` v19.4 is available from Homebrew: https://nodejs.org/api/assert.html#assertmatchstring-regexp-messageg 21 | equal( 22 | // biome-ignore lint/style/noNonNullAssertion: Error must be caught. 23 | !!caughtError!.message.match(/No `Promise` implementation available/), 24 | true, 25 | ); 26 | -------------------------------------------------------------------------------- /src/mock-test/modern-writeText.ts: -------------------------------------------------------------------------------- 1 | import { equal } from "node:assert"; 2 | import type { ClipboardWithoutEventTarget } from "../clipboard-polyfill/ClipboardItem/spec"; 3 | 4 | const mockStringClipboard = new (class MockStringClipboard { 5 | value: string = ""; 6 | setText(s: string) { 7 | this.value = s; 8 | } 9 | getText(): string { 10 | return this.value; 11 | } 12 | })(); 13 | 14 | function unimplemented(): any { 15 | throw new Error("unimplemented"); 16 | } 17 | 18 | globalThis.navigator ??= {} as any; 19 | (globalThis.navigator as any).clipboard ??= { 20 | writeText: async (s) => mockStringClipboard.setText(s), 21 | readText: async (): Promise => mockStringClipboard.getText(), 22 | read: unimplemented, 23 | write: unimplemented, 24 | } satisfies ClipboardWithoutEventTarget; 25 | 26 | // This needs to happen after the mocks are set up. 27 | const { readText, writeText } = await import( 28 | "../clipboard-polyfill/entries/es6/clipboard-polyfill.es6" 29 | ); 30 | 31 | mockStringClipboard.setText("hello world"); 32 | equal("hello world", await readText()); 33 | await writeText("new text"); 34 | equal("new text", await readText()); 35 | -------------------------------------------------------------------------------- /src/test/bun-test-cannot-run-all-tests.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | 3 | test("`bun test` must be run one file at a time", () => { 4 | console.error( 5 | "\n\n\n\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n\n\n\n[clipboard-polyfill] Each test file requires a fresh global environment before importing library code. Run `make test-bun` to run test files one at a time instead.\n\n\n\n⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️\n\n\n\n", 6 | ); 7 | expect(true).toBe(false); 8 | }); 9 | -------------------------------------------------------------------------------- /src/test/mocks.ts: -------------------------------------------------------------------------------- 1 | import { type Mock, mock } from "bun:test"; 2 | import { setDebugLog } from "../clipboard-polyfill/debug"; 3 | 4 | const emptyFunction = () => {}; 5 | const asyncEmptyFunction = async () => {}; 6 | 7 | export function createDebugLogConsoleMock(): Mock { 8 | const consoleLogMock = mock(console.log); 9 | setDebugLog(consoleLogMock); 10 | return consoleLogMock; 11 | } 12 | 13 | interface DocumentMock { 14 | addEventListener: Mock; 15 | removeEventListener: Mock; 16 | execCommand: Mock; 17 | } 18 | 19 | function assertEventNameOrCommandIsCopy(eventName: string) { 20 | if (eventName !== "copy") { 21 | throw new Error("Unexpected event name or command."); 22 | } 23 | } 24 | 25 | // TODO: Full return type. 26 | export function createDocumentMock(): { 27 | documentMock: DocumentMock; 28 | eventMock: { 29 | clipboardData: { setData: Mock; getData: Mock }; 30 | preventDefault: Mock; 31 | }; 32 | } { 33 | const listeners: Set = new Set(); // TODO 34 | 35 | // TODO: mock `DataTransfer` 36 | let textPlain: string | undefined; 37 | const eventMock = { 38 | clipboardData: { 39 | setData: mock((type: string, value: string) => { 40 | if (type !== "text/plain") { 41 | throw new Error("Unexpected type"); 42 | } 43 | textPlain = value; 44 | }), 45 | getData: mock((type: string) => { 46 | if (type !== "text/plain") { 47 | throw new Error("Unexpected type"); 48 | } 49 | return textPlain; 50 | }), 51 | }, 52 | preventDefault: mock(emptyFunction), // TODO: Expose this to test that it gets called exactly once. 53 | }; 54 | 55 | const documentMock = { 56 | addEventListener: mock((eventName, listener) => { 57 | assertEventNameOrCommandIsCopy(eventName); 58 | listeners.add(listener); 59 | }), 60 | removeEventListener: mock((eventName, listener) => { 61 | assertEventNameOrCommandIsCopy(eventName); 62 | listeners.delete(listener); 63 | }), 64 | execCommand: mock((command) => { 65 | assertEventNameOrCommandIsCopy(command); 66 | for (const listener of listeners) { 67 | listener(eventMock); 68 | } 69 | }), 70 | }; 71 | (globalThis as any).document = documentMock; 72 | return { documentMock, eventMock }; 73 | } 74 | 75 | export function createWriteTextMock( 76 | executor: (s: string) => Promise = asyncEmptyFunction, 77 | ): Mock { 78 | // biome-ignore lint/suspicious/noAssignInExpressions: DRY pattern. 79 | const navigatorMock = ((globalThis as any).navigator ??= {}); 80 | // biome-ignore lint/suspicious/noAssignInExpressions: DRY pattern. 81 | const clipboardMock = (navigatorMock.clipboard ??= {}); 82 | const writeTextMock = mock(executor); 83 | clipboardMock.writeText = writeTextMock; 84 | return writeTextMock; 85 | } 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es5", "dom"], 4 | "strictNullChecks": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "dist/types", 8 | "skipLibCheck": true 9 | }, 10 | "include": ["./src/clipboard-polyfill/entries"] 11 | } 12 | --------------------------------------------------------------------------------