├── .npmrc ├── .gitattributes ├── .gitignore ├── fixture1.jpg ├── fixture2.png ├── .editorconfig ├── .github └── workflows │ └── main.yml ├── index.test-d.ts ├── cli.js ├── package.json ├── license ├── test.js ├── index.d.ts ├── readme.md └── index.js /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /fixture1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/clipboard-image/HEAD/fixture1.jpg -------------------------------------------------------------------------------- /fixture2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/clipboard-image/HEAD/fixture2.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: macos-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 20 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import {hasClipboardImages, readClipboardImages, writeClipboardImages} from './index.js'; 3 | 4 | // HasClipboardImages returns boolean 5 | expectType>(hasClipboardImages()); 6 | 7 | // ReadClipboardImages returns string[] 8 | expectType>(readClipboardImages()); 9 | 10 | // WriteClipboardImages accepts readonly array of strings or URLs 11 | expectType>(writeClipboardImages(['a.png', 'b.jpg'] as const)); 12 | expectType>(writeClipboardImages([new URL('file:///a.png'), new URL('file:///b.jpg')] as const)); 13 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import process from 'node:process'; 3 | import {readClipboardImages, writeClipboardImages} from './index.js'; 4 | 5 | const arguments_ = process.argv.slice(2); 6 | 7 | if (arguments_.length > 0) { 8 | await writeClipboardImages(arguments_); 9 | const plural = arguments_.length > 1 ? 's' : ''; 10 | console.log(`${arguments_.length} image${plural} copied to clipboard`); 11 | } else { 12 | const files = await readClipboardImages(); 13 | if (files.length === 0) { 14 | console.error('No images found on clipboard'); 15 | process.exit(1); 16 | } 17 | 18 | console.log(files.join('\n')); 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clipboard-image", 3 | "version": "0.1.0", 4 | "description": "Get and set images on the macOS clipboard", 5 | "license": "MIT", 6 | "repository": "sindresorhus/clipboard-image", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "bin": "./cli.js", 15 | "exports": { 16 | "types": "./index.d.ts", 17 | "default": "./index.js" 18 | }, 19 | "sideEffects": false, 20 | "engines": { 21 | "node": ">=20" 22 | }, 23 | "scripts": { 24 | "test": "xo && ava" 25 | }, 26 | "files": [ 27 | "index.js", 28 | "index.d.ts", 29 | "cli.js" 30 | ], 31 | "keywords": [ 32 | "clipboard", 33 | "image", 34 | "images", 35 | "pasteboard", 36 | "macos", 37 | "mac", 38 | "copy", 39 | "paste", 40 | "nspasteboard" 41 | ], 42 | "dependencies": { 43 | "run-jxa": "^3.0.0" 44 | }, 45 | "devDependencies": { 46 | "ava": "^6.4.1", 47 | "xo": "^1.2.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import {fileURLToPath} from 'node:url'; 3 | import path from 'node:path'; 4 | import test from 'ava'; 5 | import { 6 | readClipboardImages, 7 | writeClipboardImages, 8 | hasClipboardImages, 9 | } from './index.js'; 10 | 11 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 | const fixture1 = path.join(__dirname, 'fixture1.jpg'); 13 | const fixture2 = path.join(__dirname, 'fixture2.png'); 14 | 15 | test.serial('writeClipboardImages sets images on clipboard', async t => { 16 | await writeClipboardImages([fixture1, fixture2]); 17 | t.pass(); 18 | }); 19 | 20 | test.serial('hasClipboardImages detects images on clipboard', async t => { 21 | await writeClipboardImages([fixture1, fixture2]); 22 | t.true(await hasClipboardImages()); 23 | }); 24 | 25 | test.serial('readClipboardImages returns PNG file paths', async t => { 26 | await writeClipboardImages([fixture1, fixture2]); 27 | 28 | const files = await readClipboardImages(); 29 | t.true(Array.isArray(files)); 30 | t.is(files.length, 2); 31 | 32 | for (const file of files) { 33 | const base = path.basename(file); 34 | t.true(base.startsWith('clipboard-image-')); 35 | t.true(base.endsWith('.png')); 36 | const buffer = await fs.readFile(file); // eslint-disable-line no-await-in-loop 37 | t.true(buffer.length > 0); 38 | t.true(buffer.toString('hex').startsWith('89504e47')); // PNG signature 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Check if there are images on the clipboard. 3 | 4 | @returns A promise that resolves to `true` if there are images on the clipboard, `false` otherwise. On non-macOS platforms, it returns `false`. 5 | 6 | @example 7 | ``` 8 | import {hasClipboardImages} from 'clipboard-image'; 9 | 10 | if (await hasClipboardImages()) { 11 | console.log('Images found on clipboard'); 12 | } 13 | ``` 14 | */ 15 | export function hasClipboardImages(): Promise; 16 | 17 | /** 18 | Read images from the clipboard and save them as PNG files in a unique temporary directory. 19 | 20 | You get PNG files no matter what image types they were on the clipboard to make it easier to handle. 21 | 22 | @returns A promise that resolves to an array of file paths to the saved PNG files. You are in charge of these files. You can move them somewhere else or clean them up when you are done. On non-macOS platforms, it returns an empty array. 23 | 24 | @example 25 | ``` 26 | import {readClipboardImages} from 'clipboard-image'; 27 | 28 | const files = await readClipboardImages(); 29 | //=> ['/var/folders/.../clipboard-image-1.png', '/var/folders/.../clipboard-image-2.png'] 30 | ``` 31 | */ 32 | export function readClipboardImages(): Promise; 33 | 34 | /** 35 | Write images to the clipboard. 36 | 37 | Supports any image type that macOS supports, which includes PNG, JPEG, HEIC, WebP, GIF. 38 | 39 | On non-macOS platforms, it does nothing. 40 | 41 | @param filePaths - An array of file paths and file URL objects pointing to image files. 42 | 43 | @example 44 | ``` 45 | import {writeClipboardImages} from 'clipboard-image'; 46 | 47 | await writeClipboardImages(['screenshot.png', 'photo.jpg']); 48 | ``` 49 | */ 50 | export function writeClipboardImages(filePaths: ReadonlyArray): Promise; 51 | 52 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # clipboard-image 2 | 3 | > Get and set images on the macOS clipboard 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install clipboard-image 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import { 15 | writeClipboardImages, 16 | readClipboardImages 17 | } from 'clipboard-image'; 18 | 19 | await writeClipboardImages(['screenshot.png', 'photo.jpg']); 20 | 21 | console.log(await readClipboardImages()); 22 | //=> ['/var/folders/.../clipboard-image-1.png', '/var/folders/.../clipboard-image-2.png'] 23 | ``` 24 | 25 | ## API 26 | 27 | ### `hasClipboardImages()` 28 | 29 | Check if there are images on the clipboard. 30 | 31 | Returns a `Promise`. 32 | 33 | On non-macOS platforms, it returns `false`. 34 | 35 | ### `readClipboardImages()` 36 | 37 | Read images from the clipboard. 38 | 39 | You get PNG files no matter what image types they were on the clipboard to make it easier to handle. 40 | 41 | Returns a `Promise` with paths to the saved PNG files. You are in charge of these files. You can move them somewhere else or clean them up when you are done. 42 | 43 | On non-macOS platforms, it returns an empty array. 44 | 45 | ### `writeClipboardImages(filePaths)` 46 | 47 | Write images to the clipboard. 48 | 49 | Supports any image type that macOS supports, which includes PNG, JPEG, HEIC, WebP, GIF. 50 | 51 | On non-macOS platforms, it does nothing. 52 | 53 | #### filePaths 54 | 55 | Type: `Array` 56 | 57 | An array of file paths and file URL objects pointing to image files. 58 | 59 | ## CLI 60 | 61 | ```sh 62 | # Read images from clipboard and output file paths 63 | clipboard-image 64 | 65 | # Write images to clipboard 66 | clipboard-image image1.png image2.jpg 67 | ``` 68 | 69 | When reading from clipboard, if there are images, they will be saved to a temporary directory and the file paths will be printed (one per line). 70 | 71 | When writing to clipboard, provide one or more image file paths as arguments. 72 | 73 | ## Related 74 | 75 | - [clipboardy](https://github.com/sindresorhus/clipboardy) - Access text on the system clipboard (copy/paste) 76 | - [copy-text-to-clipboard](https://github.com/sindresorhus/copy-text-to-clipboard) - Copy text to the clipboard in the browser 77 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* globals $, ObjC */ 2 | /* eslint-disable new-cap */ 3 | import process from 'node:process'; 4 | import {fileURLToPath} from 'node:url'; 5 | import {runJxa} from 'run-jxa'; 6 | 7 | export async function hasClipboardImages() { 8 | if (process.platform !== 'darwin') { 9 | return false; 10 | } 11 | 12 | const result = await runJxa(() => { 13 | ObjC.import('AppKit'); 14 | ObjC.import('Foundation'); 15 | 16 | const pasteboard = $.NSPasteboard.generalPasteboard; 17 | const {imageTypes} = $.NSImage; 18 | 19 | const options = $.NSMutableDictionary.dictionary; 20 | options.setObjectForKey( 21 | imageTypes, 22 | $('NSPasteboardURLReadingContentsConformToTypesKey'), 23 | ); 24 | options.setObjectForKey( 25 | $.NSNumber.numberWithBool(true), 26 | $('NSPasteboardURLReadingFileURLsOnlyKey'), 27 | ); 28 | 29 | const classes = $.NSMutableArray.array; 30 | classes.addObject($.NSURL); 31 | classes.addObject($.NSImage); 32 | 33 | const objects = pasteboard.readObjectsForClassesOptions(classes, options); 34 | return objects && objects.count > 0; 35 | }); 36 | 37 | return result; 38 | } 39 | 40 | export async function readClipboardImages() { 41 | if (process.platform !== 'darwin') { 42 | return []; 43 | } 44 | 45 | const result = await runJxa(() => { 46 | ObjC.import('AppKit'); 47 | ObjC.import('Foundation'); 48 | 49 | const pasteboard = $.NSPasteboard.generalPasteboard; 50 | const {imageTypes} = $.NSImage; 51 | 52 | const options = $.NSMutableDictionary.dictionary; 53 | options.setObjectForKey( 54 | imageTypes, 55 | $('NSPasteboardURLReadingContentsConformToTypesKey'), 56 | ); 57 | options.setObjectForKey( 58 | $.NSNumber.numberWithBool(true), 59 | $('NSPasteboardURLReadingFileURLsOnlyKey'), 60 | ); 61 | 62 | const classes = $.NSMutableArray.array; 63 | classes.addObject($.NSURL); 64 | classes.addObject($.NSImage); 65 | 66 | const objects = pasteboard.readObjectsForClassesOptions(classes, options); 67 | 68 | if (!objects || objects.count === 0) { 69 | return []; 70 | } 71 | 72 | const fileManager = $.NSFileManager.defaultManager; 73 | const temporaryRoot = $.NSURL.fileURLWithPathIsDirectory( 74 | $(ObjC.unwrap($.NSTemporaryDirectory())), 75 | true, 76 | ); 77 | 78 | const uuid = $.NSUUID.UUID.UUIDString; 79 | const temporaryDirectory = temporaryRoot.URLByAppendingPathComponent($(uuid)); 80 | 81 | fileManager.createDirectoryAtURLWithIntermediateDirectoriesAttributesError( 82 | temporaryDirectory, 83 | true, 84 | $(), // Nil, not NSNull 85 | $(), // Nil for error 86 | ); 87 | 88 | const resultPaths = []; 89 | const usedNames = new Set(); 90 | 91 | for (let index = 0; index < objects.count; index++) { 92 | const object = objects.objectAtIndex(index); 93 | let image = null; 94 | let filename = `clipboard-image-${index}.png`; 95 | 96 | if (object.isKindOfClass($.NSURL)) { 97 | image = $.NSImage.alloc.initWithContentsOfURL(object); 98 | const lastPathComponent = ObjC.unwrap(object.lastPathComponent); 99 | if (lastPathComponent && lastPathComponent !== '') { 100 | const nameWithoutExt = lastPathComponent.replace(/\.[^/.]+$/, ''); 101 | filename = `${nameWithoutExt}.png`; 102 | let counter = 1; 103 | while (usedNames.has(filename)) { 104 | filename = `${nameWithoutExt}-${counter}.png`; 105 | counter++; 106 | } 107 | } 108 | } else if (object.isKindOfClass($.NSImage)) { 109 | image = object; 110 | } 111 | 112 | if (!image) { 113 | continue; 114 | } 115 | 116 | const tiffData = image.TIFFRepresentation; 117 | const representation = $.NSBitmapImageRep.imageRepWithData(tiffData); 118 | 119 | if (!representation) { 120 | continue; 121 | } 122 | 123 | const pngData = representation.representationUsingTypeProperties( 124 | $.NSPNGFileType, 125 | $({}), 126 | ); 127 | 128 | usedNames.add(filename); 129 | const fileURL = temporaryDirectory.URLByAppendingPathComponent($(filename)); 130 | pngData.writeToURLAtomically(fileURL, true); 131 | resultPaths.push(ObjC.unwrap(fileURL.path)); 132 | } 133 | 134 | return resultPaths; 135 | }); 136 | 137 | return result; 138 | } 139 | 140 | export async function writeClipboardImages(filePaths) { 141 | if (process.platform !== 'darwin') { 142 | return; 143 | } 144 | 145 | // Convert URL objects to file paths 146 | const paths = filePaths.map(path => path instanceof URL ? fileURLToPath(path) : path); 147 | 148 | // RunJxa passes array elements as separate arguments to the function 149 | await runJxa((...paths) => { 150 | ObjC.import('AppKit'); 151 | ObjC.import('Foundation'); 152 | 153 | if (paths.length === 0) { 154 | throw new Error('Expected at least one image path'); 155 | } 156 | 157 | const images = $.NSMutableArray.array; 158 | for (const imagePath of paths) { 159 | const url = $.NSURL.fileURLWithPath($(imagePath)); 160 | const image = $.NSImage.alloc.initWithContentsOfURL(url); 161 | if (!image || image.isNil()) { 162 | throw new Error('Invalid image file: ' + imagePath); 163 | } 164 | 165 | images.addObject(image); 166 | } 167 | 168 | const pasteboard = $.NSPasteboard.generalPasteboard; 169 | pasteboard.clearContents; // eslint-disable-line no-unused-expressions 170 | 171 | if (pasteboard.writeObjects) { 172 | pasteboard.writeObjects(images); 173 | } else { 174 | // Fallback for older macOS versions 175 | const firstImage = images.count > 0 ? images.objectAtIndex(0) : undefined; 176 | if (!firstImage) { 177 | return; 178 | } 179 | 180 | const tiffData = firstImage.TIFFRepresentation; 181 | const representation = $.NSBitmapImageRep.imageRepWithData(tiffData); 182 | if (!representation) { 183 | return; 184 | } 185 | 186 | const pngData = representation.representationUsingTypeProperties( 187 | $.NSPNGFileType, 188 | $({}), 189 | ); 190 | 191 | pasteboard.setDataForType(pngData, $('public.png')); 192 | } 193 | }, paths); 194 | } 195 | --------------------------------------------------------------------------------