├── .gitignore ├── src ├── image-palette │ ├── image-palette.html │ ├── logo.png │ ├── image.png │ ├── manifest.json │ ├── image-palette.ui.js │ └── image-palette.main.js ├── index.html └── util │ ├── io.js │ └── image.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | bundle.js 5 | .cache 6 | dist/ 7 | -------------------------------------------------------------------------------- /src/image-palette/image-palette.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/image-palette/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/figma-plugin-palette/HEAD/src/image-palette/logo.png -------------------------------------------------------------------------------- /src/image-palette/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/figma-plugin-palette/HEAD/src/image-palette/image.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 |
This file is generated by fika and used internally. You probably want to look within the plugin folders for individual HTML files.
2 | -------------------------------------------------------------------------------- /src/image-palette/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Image Palette", 3 | "api": "1.0.0", 4 | "main": "image-palette.main.js", 5 | "ui": "image-palette.html", 6 | "id": "731841207668879837" 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-plugin-palette", 3 | "version": "1.0.0", 4 | "description": "A Figma plugin", 5 | "scripts": { 6 | "start": "fika image-palette", 7 | "build": "fika image-palette --build" 8 | }, 9 | "keywords": [], 10 | "browserslist": [ 11 | "last 1 Chrome version" 12 | ], 13 | "alias": { 14 | "figma-plugin-palette": "./src/" 15 | }, 16 | "devDependencies": { 17 | "@mattdesl/fika": "^1.0.7" 18 | }, 19 | "dependencies": { 20 | "get-rgba-palette": "^2.0.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/image-palette/image-palette.ui.js: -------------------------------------------------------------------------------- 1 | import { ui as io } from 'figma-plugin-palette/util/io'; 2 | import { decodeImage, getPixels } from 'figma-plugin-palette/util/image'; 3 | import getRGBAPalette from 'get-rgba-palette'; 4 | 5 | // To speed up palette generation for large images... 6 | const maxDimension = 1024; 7 | 8 | (async () => { 9 | io.send('loaded'); 10 | 11 | io.on('extract-palette', async ({ id, bytes, count = 5 }) => { 12 | const image = await decodeImage(bytes); 13 | const pixels = getPixels(image, { maxDimension }); 14 | const palette = getRGBAPalette(pixels, count); 15 | io.send('palette', { 16 | id, 17 | palette 18 | }); 19 | }); 20 | })(); 21 | -------------------------------------------------------------------------------- /src/util/io.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | function createInterface (renderer) { 4 | const emitter = new EventEmitter(); 5 | 6 | const receive = result => { 7 | if (result && result.event) { 8 | emitter.emit(result.event, result.data); 9 | } 10 | }; 11 | 12 | if (renderer) { 13 | window.onmessage = ev => receive(ev.data.pluginMessage); 14 | } else { 15 | figma.ui.onmessage = data => receive(data); 16 | } 17 | 18 | emitter.send = function (event, data) { 19 | if (typeof event !== 'string') { 20 | throw new Error('Expected first argument to be an event name string'); 21 | } 22 | const postData = { 23 | event, 24 | data 25 | }; 26 | if (renderer) { 27 | window.parent.postMessage({ pluginMessage: postData }, '*'); 28 | } else { 29 | figma.ui.postMessage(postData); 30 | } 31 | }; 32 | 33 | emitter.async = function (ev) { 34 | return new Promise((resolve) => { 35 | this.once(ev, resolve); 36 | }); 37 | }; 38 | 39 | return emitter; 40 | } 41 | 42 | const isRenderer = typeof figma === 'undefined'; 43 | export const ui = isRenderer ? createInterface(true) : undefined; 44 | export const main = isRenderer ? undefined : createInterface(); 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # figma-plugin-palette 2 | 3 | Source code for the "Image Palette" plugin: 4 | 5 | https://www.figma.com/c/plugin/731841207668879837/Image-Palette 6 | 7 | # Usage 8 | 9 | If you want to run this locally, you'll need node@8.x, npm@6.1.x and Figma's Desktop Application. 10 | 11 | First clone this repo and install dependencies: 12 | 13 | ```sh 14 | git clone https://github.com/mattdesl/figma-plugin-palette.git 15 | cd figma-plugin-palette 16 | npm install 17 | ``` 18 | 19 | Then you can run the development version: 20 | 21 | ```sh 22 | npm run start 23 | ``` 24 | 25 | This will generate a `dist` folder. 26 | 27 | Now open a project in Figma Desktop, select Menu > Plugins > Development > New Plugin. Click "Choose a manifest.json" and find the `manifest.json` file in `figma-plugin-palette/dist/image-palette/manifest.json`. 28 | 29 | Now you can run the plugin via Right Click > Plugins > Image Palette. You can also open the Console via that context menu, and re-run the last plugin via Cmd + Option + P. 30 | 31 | # Production Build 32 | 33 | Once you are happy, you can run the following to build a standalone and more optimized version: 34 | 35 | ```sh 36 | npm run build 37 | ``` 38 | 39 | Note that you will need to change the `"id"` in `./src/image-palette/manifest.json` if you wish to publish changes to your own plugin. -------------------------------------------------------------------------------- /src/util/image.js: -------------------------------------------------------------------------------- 1 | let canvas, context; 2 | 3 | function getTemporaryCanvasContext () { 4 | if (!canvas || !context) { 5 | canvas = document.createElement('canvas'); 6 | context = canvas.getContext('2d'); 7 | } 8 | return { canvas, context }; 9 | } 10 | 11 | export function getPixels (image, opts = {}) { 12 | const { maxDimension = Infinity } = opts; 13 | let { width, height } = image; 14 | 15 | // rescale if necessary 16 | if (width > maxDimension || height > maxDimension) { 17 | const aspect = width / height; 18 | if (width >= height) { 19 | width = maxDimension; 20 | height = Math.floor(width / aspect); 21 | } else { 22 | height = maxDimension; 23 | width = Math.floor(height * aspect); 24 | } 25 | } 26 | 27 | const { canvas, context } = getTemporaryCanvasContext(); 28 | canvas.width = width; 29 | canvas.height = height; 30 | context.clearRect(0, 0, canvas.width, canvas.height); 31 | context.drawImage(image, 0, 0, width, height); 32 | 33 | return context.getImageData(0, 0, width, height).data; 34 | } 35 | 36 | export async function encodeImageData (imageData) { 37 | const { canvas, context } = getTemporaryCanvasContext(); 38 | canvas.width = imageData.width; 39 | canvas.height = imageData.height; 40 | context.clearRect(0, 0, canvas.width, canvas.height); 41 | context.putImageData(imageData, 0, 0); 42 | return encodeCanvas(canvas); 43 | } 44 | 45 | export async function encodeImage (image) { 46 | const { canvas, context } = getTemporaryCanvasContext(); 47 | canvas.width = image.width; 48 | canvas.height = image.height; 49 | context.clearRect(0, 0, canvas.width, canvas.height); 50 | context.drawImage(image, 0, 0); 51 | return encodeCanvas(canvas); 52 | } 53 | 54 | export async function encodeCanvas (canvas) { 55 | return new Promise((resolve, reject) => { 56 | canvas.toBlob(blob => { 57 | const reader = new window.FileReader(); 58 | reader.onload = () => resolve(new Uint8Array(reader.result)); 59 | reader.onerror = () => reject(new Error('Could not read from blob')); 60 | reader.readAsArrayBuffer(blob); 61 | }); 62 | }); 63 | } 64 | 65 | export async function decodeImage (bytes) { 66 | const blob = new window.Blob([bytes]); 67 | const url = URL.createObjectURL(blob); 68 | const image = await new Promise((resolve, reject) => { 69 | const img = new window.Image(); 70 | img.onload = () => resolve(img); 71 | img.onerror = () => reject(new Error(`Could not decode bytes due to an error`)); 72 | img.src = url; 73 | }); 74 | window.URL.revokeObjectURL(blob); 75 | return image; 76 | } 77 | -------------------------------------------------------------------------------- /src/image-palette/image-palette.main.js: -------------------------------------------------------------------------------- 1 | import { main as io } from 'figma-plugin-palette/util/io'; 2 | 3 | const TIMEOUT = 7500; 4 | const PALETTE_COUNT = 5; 5 | 6 | function getImageFill (node) { 7 | if (!node.fills || node.fills.length === 0) { 8 | return null; 9 | } 10 | 11 | const fills = node.fills; 12 | const fill = fills.find(fill => { 13 | return fill.type === 'IMAGE' && fill.imageHash; 14 | }); 15 | return fill || null; 16 | } 17 | 18 | function getSelectedImageNodes () { 19 | const items = figma.currentPage.selection.map(node => { 20 | const children = typeof node.findAll === 'function' 21 | ? node.findAll(child => Boolean(getImageFill(child))) 22 | : []; 23 | const nodes = [ node, ...children ].filter(Boolean); 24 | const fills = nodes.map(n => getImageFill(n)).filter(Boolean); 25 | return fills.map(fill => { 26 | const image = figma.getImageByHash(fill.imageHash); 27 | return { node, fill, image }; 28 | }); 29 | }).reduce((a, b) => a.concat(b), []); 30 | 31 | // de-duplicate 32 | const uniques = []; 33 | const ids = {}; 34 | items.forEach(item => { 35 | if (!(item.node.id in ids)) { 36 | ids[item.node.id] = true; 37 | uniques.push(item); 38 | } 39 | }); 40 | return uniques; 41 | } 42 | 43 | async function retrievePalette (node, image) { 44 | const bytes = await image.getBytesAsync(); 45 | const currentID = node.id; 46 | io.send('extract-palette', { 47 | count: PALETTE_COUNT, 48 | id: currentID, 49 | bytes 50 | }); 51 | return new Promise((resolve, reject) => { 52 | let timeout = setTimeout(() => { 53 | reject(new Error(`Timed out on node ${currentID}`)); 54 | }, TIMEOUT); 55 | 56 | io.on('palette', ({ palette, id }) => { 57 | if (id === currentID) { 58 | clearTimeout(timeout); 59 | resolve(palette); 60 | } 61 | }); 62 | }); 63 | } 64 | 65 | function createPaletteGroup (node, palette) { 66 | const interval = node.width / palette.length; 67 | const padding = node.width * 0.025; 68 | const cellSize = interval - padding; 69 | const nodes = palette.map((color, i) => { 70 | const ellipse = figma.createEllipse(); 71 | // node.parent.appendChild(ellipse); 72 | ellipse.resize(cellSize, cellSize); 73 | ellipse.x = padding / 2 + node.x + i * (interval); 74 | ellipse.y = node.y + node.height + padding; 75 | 76 | const newFills = JSON.parse(JSON.stringify(ellipse.fills)); 77 | newFills[0].color.r = color[0] / 255; 78 | newFills[0].color.g = color[1] / 255; 79 | newFills[0].color.b = color[2] / 255; 80 | ellipse.fills = newFills; 81 | ellipse.name = `Color${i}`; 82 | return ellipse; 83 | }); 84 | 85 | const reversed = nodes.slice(); 86 | reversed.reverse(); 87 | reversed.forEach(n => node.parent.appendChild(n)); 88 | 89 | const group = figma.group([ node, ...nodes ], node.parent); 90 | group.name = `${node.name} Palette`; 91 | return group; 92 | } 93 | 94 | (async () => { 95 | const selectedImages = getSelectedImageNodes(); 96 | 97 | if (selectedImages.length === 0) { 98 | figma.closePlugin('You must select at least one image.'); 99 | } else { 100 | // Show hidden decoder UI 101 | figma.showUI(__html__, { visible: false }); 102 | 103 | // Make sure UI is fully loaded before sending bytes over 104 | await io.async('loaded'); 105 | 106 | let groups = []; 107 | await selectedImages.reduce(async (promise, { node, image }) => { 108 | await promise; 109 | try { 110 | const palette = await retrievePalette(node, image); 111 | const group = createPaletteGroup(node, palette); 112 | groups.push(group); 113 | } catch (err) { 114 | console.error(err); 115 | } 116 | }, Promise.resolve()); 117 | 118 | figma.currentPage.selection = groups; 119 | figma.closePlugin(); 120 | } 121 | })(); 122 | --------------------------------------------------------------------------------