├── .appcast.xml ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── icon.png └── placeholder.png ├── docs ├── unsplash-screenshot-001.png ├── unsplash-screenshot-002.png └── unsplash-screenshot-003.png ├── package-lock.json ├── package.json └── src ├── DataProvider.js ├── manifest.json └── unsplash.js /.appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 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 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": "standard", 3 | "globals": { 4 | "context": false, 5 | "nil": false, 6 | "NSURL": false, 7 | "NSURLRequest": false, 8 | "fetch": false, 9 | "NSProcessInfo": false, 10 | "NSFileManager": false, 11 | "NSURLConnection": false, 12 | "NSTemporaryDirectory": false, 13 | "MSImageData": false, 14 | "NSImage": false, 15 | "error": true, 16 | "NSWorkspace": false 17 | } 18 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artifacts 2 | unsplash.sketchplugin 3 | *.zip 4 | 5 | # npm 6 | node_modules 7 | .npm 8 | npm-debug.log 9 | 10 | # mac 11 | .DS_Store 12 | 13 | # WebStorm 14 | .idea 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2019 Bohemian Coding 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unsplash Data Plugin 2 | 3 | ## Installation 4 | 5 | The plugin comes bundled with Sketch since version 52, but if for some reason you’ve lost it, you can [download it from the releases page](https://github.com/sketch-hq/unsplash-sketchplugin/releases/latest). 6 | 7 | ## Features 8 | 9 | Get images from Unsplash, using Sketch 52’s new Data Supplier feature. 10 | 11 | You can use it from the toolbar Data icon… 12 | 13 | ![Using the Unsplash Data Plugin from the toolbar icon](docs/unsplash-screenshot-001.png) 14 | 15 | …or from the contextual menu for any layer… 16 | 17 | ![Using the Unsplash Data Plugin from the contextual menu](docs/unsplash-screenshot-002.png) 18 | 19 | …or even for Overrides using the Inspector: 20 | 21 | ![Using the Unsplash Data Plugin for Overrides from the Inspector](docs/unsplash-screenshot-003.png) 22 | 23 | When you’ve set an image fill for a layer, you can later on open the original URL for an image, in case you need to full credits, or download the original file, etc. To do that, click Plugins › Unsplash › View Photo on Unsplash. It does not work for overrides yet, but we’re working on it! 24 | 25 | ## Use specific photo 26 | 27 | If you don't want a random photo, but a specific one, you can do a search in unsplash.com and then use the URL for the image you want as the input. 28 | 29 | To do that, select the "Search Photo…" option then in the input field paste in an Unsplash photo URL. If you'd rather use the ID of the image, you can do that by entering `id:photo_id` as the search term. 30 | 31 | (Thanks for this feature :) 32 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/assets/icon.png -------------------------------------------------------------------------------- /assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/assets/placeholder.png -------------------------------------------------------------------------------- /docs/unsplash-screenshot-001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/docs/unsplash-screenshot-001.png -------------------------------------------------------------------------------- /docs/unsplash-screenshot-002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/docs/unsplash-screenshot-002.png -------------------------------------------------------------------------------- /docs/unsplash-screenshot-003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sketch-hq/unsplash-sketchplugin/998aa79ecf97e7de1abfcf6263afd5f62375c49e/docs/unsplash-screenshot-003.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unsplash", 3 | "version": "1.1.1", 4 | "engines": { 5 | "sketch": ">=3.0" 6 | }, 7 | "skpm": { 8 | "manifest": "src/manifest.json", 9 | "main": "unsplash.sketchplugin", 10 | "assets": [ 11 | "assets/**/*" 12 | ] 13 | }, 14 | "scripts": { 15 | "build": "skpm-build", 16 | "watch": "skpm-build --watch", 17 | "start": "skpm-build --watch --run", 18 | "postinstall": "npm run build && skpm-link" 19 | }, 20 | "devDependencies": { 21 | "@skpm/builder": "^0.7.7", 22 | "eslint": "^6.5.1", 23 | "eslint-config-standard": "^14.1.1", 24 | "eslint-plugin-import": "^2.20.2", 25 | "eslint-plugin-node": "^11.1.0", 26 | "eslint-plugin-promise": "^4.2.1", 27 | "eslint-plugin-standard": "^4.0.1", 28 | "lodash": "^4.17.21", 29 | "serialize-javascript": ">=3.1.0" 30 | }, 31 | "author": "Sketch", 32 | "repository": "https://github.com/sketch-hq/unsplash-sketchplugin.git", 33 | "description": "Easily grab images from Unsplash", 34 | "license": "MIT", 35 | "dependencies": { 36 | "@skpm/buffer": "^0.1.4", 37 | "@skpm/fs": "^0.2.6", 38 | "sketch-image-downloader": "^1.0.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/DataProvider.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const path = require('path') 3 | const util = require('util') 4 | const fs = require('@skpm/fs') 5 | const sketch = require('sketch') 6 | const { getImagesURLsForItems } = require('./unsplash') 7 | 8 | const { DataSupplier, UI, Settings } = sketch 9 | 10 | const { insertImage, getImageFromURL } = require('sketch-image-downloader') 11 | 12 | const SETTING_KEY = 'unsplash.photo.id' 13 | const FOLDER = path.join(os.tmpdir(), 'com.sketchapp.unsplash-plugin') 14 | 15 | export function onStartup () { 16 | DataSupplier.registerDataSupplier('public.image', 'Random Photo', 'SupplyRandomPhoto') 17 | DataSupplier.registerDataSupplier('public.image', 'Search Photo…', 'SearchPhoto') 18 | } 19 | 20 | export function onShutdown () { 21 | DataSupplier.deregisterDataSuppliers() 22 | try { 23 | if (fs.existsSync(FOLDER)) { 24 | fs.rmdirSync(FOLDER) 25 | } 26 | } catch (err) { 27 | console.error(err) 28 | } 29 | } 30 | 31 | export function onSupplyRandomPhoto (context) { 32 | setImageForContext(context) 33 | } 34 | 35 | function containsPhotoId (searchTerm) { 36 | return searchTerm.substr(0, 3) === 'id:' || searchTerm.indexOf('unsplash.com/photos/') !== -1 37 | } 38 | 39 | function extractPhotoId (searchTerm) { 40 | if (searchTerm.substr(0, 3) === 'id:') { 41 | return searchTerm.substr(3) 42 | } 43 | 44 | // Extract photoId from a "unsplash.com/photos/" URL 45 | // Allows a URL with or without http/https 46 | // It also strips out anything after the photoId 47 | let photoId = searchTerm.substr(searchTerm.indexOf('unsplash.com/photos/') + 20) 48 | const artifactLocation = photoId.search(/[^a-z0-9_-]/i) 49 | return artifactLocation !== -1 ? photoId.substr(0, artifactLocation) : photoId 50 | } 51 | 52 | export function onSearchPhoto (context) { 53 | // 21123: retrieve previous search term. If multiple layers are selected, find the first search term 54 | // in the group… 55 | let selectedLayers = sketch.getSelectedDocument().selectedLayers.layers 56 | let previousTerms = selectedLayers.map(layer => Settings.layerSettingForKey(layer, 'unsplash.search.term')) 57 | let firstPreviousTerm = previousTerms.find(term => term !== undefined) 58 | let previousTerm = firstPreviousTerm || 'People' 59 | // TODO: support multiple selected layers with different search terms for each 60 | if (sketch.version.sketch < 53) { 61 | const searchTerm = UI.getStringFromUser('Search Unsplash for…', previousTerm).trim() 62 | if (searchTerm !== 'null') { 63 | selectedLayers.forEach(layer => { 64 | Settings.setLayerSettingForKey(layer, 'unsplash.search.term', searchTerm) 65 | }) 66 | searchTerm = encodeURI(searchTerm) 67 | if (containsPhotoId(searchTerm)) { 68 | setImageForContext(context, null, extractPhotoId(searchTerm)) 69 | } else { 70 | setImageForContext(context, searchTerm.replace(/\s+/g, '-').toLowerCase()) 71 | } 72 | } 73 | } else { 74 | UI.getInputFromUser('Search Unsplash for…', 75 | { initialValue: previousTerm }, 76 | (err, searchTerm) => { 77 | if (err) { return } // user hit cancel 78 | if ((searchTerm = searchTerm.trim()) !== 'null') { 79 | selectedLayers.forEach(layer => { 80 | Settings.setLayerSettingForKey(layer, 'unsplash.search.term', searchTerm) 81 | }) 82 | searchTerm = encodeURI(searchTerm) 83 | if (containsPhotoId(searchTerm)) { 84 | setImageForContext(context, null, extractPhotoId(searchTerm)) 85 | } else { 86 | setImageForContext(context, searchTerm.replace(/\s+/g, '-').toLowerCase()) 87 | } 88 | } 89 | } 90 | ) 91 | } 92 | } 93 | 94 | export default function onImageDetails () { 95 | const selectedDocument = sketch.getSelectedDocument() 96 | const selection = selectedDocument ? selectedDocument.selectedLayers : [] 97 | if (selection.length > 0) { 98 | selection.forEach(element => { 99 | const id = Settings.layerSettingForKey(element, SETTING_KEY) || ( 100 | element.type === 'SymbolInstance' && 101 | element.overrides 102 | .map(o => Settings.layerSettingForKey(o, SETTING_KEY)) 103 | .find(s => !!s) 104 | ) 105 | if (id) { 106 | NSWorkspace.sharedWorkspace().openURL(NSURL.URLWithString(`https://unsplash.com/photos/${id}`)) 107 | } else { 108 | // This layer doesn't have an Unsplash photo set, do nothing. 109 | // Alternatively, show an explanation of what the user needs to do to make this work… 110 | UI.message(`To get a random photo, click Data › Unsplash › Random Photo in the toolbar, or right click the layer › Data Feeds › Unsplash › Random Photo`) 111 | } 112 | }) 113 | } else { 114 | UI.message(`Please select at least one layer`) 115 | } 116 | } 117 | 118 | function setImageForContext (context, searchTerm, photoId) { 119 | const dataKey = context.data.key 120 | const items = util.toArray(context.data.items).map(sketch.fromNative) 121 | 122 | UI.message('🕑 Downloading…') 123 | getImagesURLsForItems(items, { searchTerm, photoId }) 124 | .then(res => Promise.all(res.map(({ data, item, index, frame, error }) => { 125 | if (error) { 126 | UI.message(error) 127 | console.error(error) 128 | } else { 129 | process(data, dataKey, index, item, frame) 130 | } 131 | }))) 132 | .catch(e => { 133 | UI.message(e) 134 | console.error(e) 135 | }) 136 | } 137 | 138 | function process (data, dataKey, index, item, frame) { 139 | // supply the data 140 | let url = `${data.urls.full}&fit=min&w=${frame.width*2}&h=${frame.height*2}` 141 | return getImageFromURL(url).then(imagePath => { 142 | if (!imagePath) { 143 | // TODO: something wrong happened, show something to the user 144 | return 145 | } 146 | DataSupplier.supplyDataAtIndex(dataKey, imagePath, index) 147 | 148 | // store where the image comes from, but only if this is a regular layer 149 | if (item.type !== 'DataOverride') { 150 | Settings.setLayerSettingForKey(item, SETTING_KEY, data.id) 151 | } 152 | 153 | UI.message('📷 by ' + data.user.name + ' on Unsplash') 154 | }) 155 | } 156 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Unsplash", 3 | "compatibleVersion": 3, 4 | "bundleVersion": 1, 5 | "icon": "icon.png", 6 | "suppliesData" : true, 7 | "commands": [ 8 | { 9 | "name": "View Photo on Unsplash", 10 | "identifier": "imageDetails", 11 | "script": "DataProvider.js", 12 | "handler": { 13 | "run": "onImageDetails" 14 | } 15 | }, 16 | { 17 | "script" : "DataProvider.js", 18 | "handlers" : { 19 | "actions" : { 20 | "Startup" : "onStartup", 21 | "Shutdown" : "onShutdown", 22 | "SupplyRandomPhoto" : "onSupplyRandomPhoto", 23 | "SearchPhoto" : "onSearchPhoto" 24 | } 25 | } 26 | } 27 | ], 28 | "menu": { 29 | "title": "Unsplash", 30 | "items": [ 31 | "imageDetails" 32 | ] 33 | } 34 | } -------------------------------------------------------------------------------- /src/unsplash.js: -------------------------------------------------------------------------------- 1 | /* globals CGPathGetBoundingBox */ 2 | const sketch = require('sketch') 3 | const { toArray } = require('util') 4 | const API_KEY = 'bfd993ac8c14516588069b3fc664b216d0e20fb9b9fa35aa06fcc3ba6e0bc703' 5 | const API_ENDPOINT = 'https://api.unsplash.com' 6 | const apiOptions = { 7 | 'headers': { 8 | 'app-pragma': 'no-cache' 9 | } 10 | } 11 | 12 | function flatten (arrays) { 13 | return arrays.reduce((prev, array) => prev.concat(array), []) 14 | } 15 | 16 | export function getImagesURLsForItems (items, { searchTerm, photoId }) { 17 | const orientations = items.reduce((prev, item, index) => { 18 | if (!item.type) { 19 | // if we get an unknown item, it means that we have a layer that is not yet 20 | // recognized by the API (probably an MSOvalShape or something) 21 | // force cast it to a Shape 22 | item = sketch.Shape.fromNative(item.sketchObject) 23 | } 24 | let layer 25 | if (item.type === 'DataOverride') { 26 | // only available on Sketch 54+ 27 | const overrideFrame = item.override.getFrame && item.override.getFrame() 28 | if (overrideFrame) { 29 | layer = { 30 | frame: overrideFrame 31 | } 32 | } else { 33 | const overrideRepresentation = toArray( 34 | item.symbolInstance.sketchObject.overrideContainer().flattenedChildren() 35 | ).find( 36 | // eslint-disable-next-line eqeqeq 37 | x => x.availableOverride() == item.override.sketchObject 38 | ) 39 | if (!overrideRepresentation) { 40 | layer = item.symbolInstance 41 | } else { 42 | const path = overrideRepresentation.pathInInstance() 43 | const bounds = CGPathGetBoundingBox(path) 44 | layer = { 45 | frame: { 46 | width: Number(bounds.size.width), 47 | height: Number(bounds.size.height) 48 | } 49 | } 50 | } 51 | } 52 | } else { 53 | layer = item 54 | } 55 | 56 | if (layer.frame.width > layer.frame.height) { 57 | prev.landscape.push({ item, index, frame: layer.frame }) 58 | } else if (layer.frame.width < layer.frame.height) { 59 | prev.portrait.push({ item, index, frame: layer.frame }) 60 | } else if (layer.frame.width === layer.frame.height) { 61 | prev.squarish.push({ item, index, frame: layer.frame }) 62 | } 63 | return prev 64 | }, { 65 | landscape: [], 66 | portrait: [], 67 | squarish: [] 68 | }) 69 | 70 | let action = photoId ? `/photos/${photoId}` : '/photos/random' 71 | let url = API_ENDPOINT + action + '?client_id=' + API_KEY 72 | 73 | if (photoId) { 74 | return fetch(url, apiOptions) 75 | .then(response => response.json()) 76 | .then(json => { 77 | if (json.errors) { 78 | return Promise.reject(json.errors[0]) 79 | } 80 | json = new Array(items.length).fill(json) 81 | return json.map((data, j) => ({ 82 | data, 83 | ...flatten(Object.values(orientations))[j] 84 | })) 85 | }).catch(error => { 86 | return [{ error }] 87 | }) 88 | } 89 | 90 | if (searchTerm) { 91 | url += '&query=' + searchTerm 92 | } 93 | 94 | 95 | return Promise.all(Object.keys(orientations).map(orientation => { 96 | const itemsForOrientation = orientations[orientation] 97 | if (!itemsForOrientation || !itemsForOrientation.length) { 98 | return Promise.resolve([]) 99 | } 100 | 101 | // we can only request 30 photos max at a time 102 | // (from https://unsplash.com/documentation#pagination) 103 | const numberOfRequests = Math.ceil(itemsForOrientation.length / 30) 104 | 105 | return Promise.all(Array(numberOfRequests).fill().map((_, i) => { 106 | // we only itemsForOrientation % 30 photos on the last request 107 | const count = i === numberOfRequests - 1 ? (itemsForOrientation.length % 30) : 30 108 | 109 | return fetch(`${url}&count=${count}&orientation=${orientation}`, apiOptions) 110 | .then(response => response.json()) 111 | .then(json => { 112 | if (json.errors) { 113 | return Promise.reject(json.errors[0]) 114 | } 115 | return json.map((data, j) => ({ 116 | data, 117 | ...itemsForOrientation[30 * i + j] 118 | })) 119 | }).catch(error => { 120 | // don't reject the promise here so that we can 121 | // at least can provide data for the others 122 | return [{ error }] 123 | }) 124 | })).then(flatten) 125 | })).then(flatten) 126 | } 127 | --------------------------------------------------------------------------------