├── ReplaceColour.sketchplugin └── Contents │ ├── Resources │ ├── hue.png │ ├── fill-colour.png │ ├── text-colour.png │ ├── border-colour.png │ ├── shadow-colour.png │ └── take-your-pick.png │ └── Sketch │ ├── manifest.json │ └── script.cocoascript └── readme.md /ReplaceColour.sketchplugin/Contents/Resources/hue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewishowles/sketch-replace-colour/HEAD/ReplaceColour.sketchplugin/Contents/Resources/hue.png -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Resources/fill-colour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewishowles/sketch-replace-colour/HEAD/ReplaceColour.sketchplugin/Contents/Resources/fill-colour.png -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Resources/text-colour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewishowles/sketch-replace-colour/HEAD/ReplaceColour.sketchplugin/Contents/Resources/text-colour.png -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Resources/border-colour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewishowles/sketch-replace-colour/HEAD/ReplaceColour.sketchplugin/Contents/Resources/border-colour.png -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Resources/shadow-colour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewishowles/sketch-replace-colour/HEAD/ReplaceColour.sketchplugin/Contents/Resources/shadow-colour.png -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Resources/take-your-pick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewishowles/sketch-replace-colour/HEAD/ReplaceColour.sketchplugin/Contents/Resources/take-your-pick.png -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Sketch/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : "Lewis Howles", 3 | "commands" : [ 4 | { 5 | "script" : "script.cocoascript", 6 | "handler" : "onRun", 7 | "shortcut" : "ctrl shift r", 8 | "name" : "Replace Colour", 9 | "identifier" : "replacecolour" 10 | } 11 | ], 12 | "menu" : { 13 | "items" : [ 14 | "replacecolour" 15 | ], 16 | "title" : "Replace Colour" 17 | }, 18 | "identifier" : "com.example.sketch.sketch-replace-colour", 19 | "version" : "2.1.2", 20 | "description" : "Replace all fill, border, shadow or text colours matching the selected layer's colour with the specified hex, or update matching hue values with the hue from the specified hex or hue value", 21 | "authorEmail" : "", 22 | "name" : "Sketch Replace Colour" 23 | } 24 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Sketch Replace Colour 2 | 3 | Sketch Replace Colour is a plugin to Find and Replace Fill, Border and Text colours on layers. 4 | 5 | By selecting a layer and running Replace Colour `(Ctrl + Shift + R)` on a selected layer, you will be presented with one of four dialogues. 6 | 7 | ## New! You can now replace hues to change families of colour in one go 8 | 9 | When selecting Hue, you can either enter a Hex value, the hue of which will be extracted, or a hue value directly. 10 | 11 | ![Sketch Replace Colour showing new Hue options](/ReplaceColour.sketchplugin/Contents/Resources/hue.png?raw=true) 12 | 13 | ## Fill only 14 | 15 | If the layer only has a fill colour specified, the popup asks for a replacement, and which properties you'd like to update on all other layers `(All, Fill, Border, Text or Shadow)`. 16 | 17 | ![Sketch Replace Colour notifying that fill colour will be selected, and asking for a replacement colour and which properties of other layers to update](/ReplaceColour.sketchplugin/Contents/Resources/fill-colour.png?raw=true) 18 | 19 | ## Border only 20 | 21 | Similar to fill only, the original colour takes the form of a notification, and further information is requested. 22 | 23 | ![Sketch Replace Colour notifying that border colour will be selected, and asking for a replacement colour and which properties of other layers to update](/ReplaceColour.sketchplugin/Contents/Resources/border-colour.png?raw=true) 24 | 25 | ## Shadow only 26 | 27 | Similar to fill only, the original colour takes the form of a notification, and further information is requested. 28 | 29 | ![Sketch Replace Colour notifying that shadow colour will be selected, and asking for a replacement colour and which properties of other layers to update](/ReplaceColour.sketchplugin/Contents/Resources/shadow-colour.png?raw=true) 30 | 31 | ## Text colour 32 | 33 | If the layer is a text layer, its colour will be taken into account. If the text layer also has a fill, this will override the text colour (as it will be the colour you can see). Text layers always have a colour, but may also have a shadow. 34 | 35 | ![Sketch Replace Colour notifying that text colour will be selected, and asking for a replacement colour and which properties of other layers to update](/ReplaceColour.sketchplugin/Contents/Resources/text-colour.png?raw=true) 36 | 37 | ## Ambiguous (Fill, Border, Shadow) 38 | 39 | If a shape layer has both multiple of fill, border and shadow specified, you will be asked to clarify which you'd like to use. 40 | 41 | ![Sketch Replace Colour requesting clarification for which property to take the initial colour from, and asking for a replacement colour and which properties of other layers to update](/ReplaceColour.sketchplugin/Contents/Resources/take-your-pick.png?raw=true) 42 | 43 | ## Installation 44 | 45 | 1. Download and unzip Sketch Replace Colour. 46 | 2. Double click `ReplaceColour.sketchplugin` for auto installation. 47 | 48 | *or* 49 | 50 | Find in [SketchToolbox](http://sketchtoolbox.com/) 51 | 52 | ## Like it? Great! 53 | 54 | If you find this plugin useful, consider [buying me a Pepsi Max](https://paypal.me/howles/5) (it's just tastier than coffee!) 55 | -------------------------------------------------------------------------------- /ReplaceColour.sketchplugin/Contents/Sketch/script.cocoascript: -------------------------------------------------------------------------------- 1 | var onRun = function(context) { 2 | var sketch = context.document; 3 | var selection = context.selection; 4 | var layerCount = selection.count(); 5 | 6 | // Plugin requires a selection to determine the starting colour 7 | if (layerCount == 0) { 8 | sketch.displayMessage('No layer selected'); 9 | } else { 10 | // Options window 11 | var selectedLayer = getSelectedLayer(selection.firstObject()); 12 | var selectedLayerType = selectedLayer.class(); 13 | var originalFill = selectedLayer.style().fills().firstObject(); 14 | var originalBorder = selectedLayer.style().borders().firstObject(); 15 | var originalShadow = selectedLayer.style().shadows().firstObject(); 16 | var alert = buildDialog(selectedLayer, selectedLayerType, originalFill, originalBorder, originalShadow); 17 | var options = handleAlertResponse(alert, alert.runModal(), selectedLayerType, originalFill, originalBorder, originalShadow); 18 | 19 | // Colours 20 | var colour = getColour(selectedLayer, options.layerProperty); 21 | var originalColour = "#" + colour.hexValue(); 22 | var replacementColour = options.colour; 23 | 24 | if (replacementColour != undefined) { 25 | var i, j, layer; 26 | 27 | // All pages 28 | for (i = 0; i < sketch.pages().count(); i++) { 29 | var page = sketch.pages().objectAtIndex(i); 30 | 31 | // All artboards 32 | for (j = 0; j < page.artboards().count(); j++) { 33 | var artboard = page.artboards().objectAtIndex(j); 34 | 35 | // All layers inside artboard 36 | for (var k = 0; k < artboard.layers().count(); k++) { 37 | layer = artboard.layers().objectAtIndex(k); 38 | 39 | checkLayer(layer, options, originalColour); 40 | } 41 | } 42 | 43 | // All layers inside page 44 | for (j = 0; j < page.layers().count(); j++) { 45 | layer = page.layers().objectAtIndex(j); 46 | 47 | checkLayer(layer, options, originalColour); 48 | } 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Build dialog window with appropriate options 55 | * @param {NSLayer} selectedLayer The layer selected at run time 56 | * @param {NSLayer} selectedLayerType The layer type of the layer selected at run time 57 | * @param {MSStyleFill} originalFill The layer's original fill 58 | * @param {MSStyleBorder} originalBorder The layer's original border 59 | * @param {MSStyleShadow} originalShadow The layer's original shadow 60 | * @return {COSAlertWindow} The alert window requesting input 61 | */ 62 | function buildDialog(selectedLayer, selectedLayerType, originalFill, originalBorder, originalShadow) { 63 | var alert = COSAlertWindow.new(); 64 | 65 | alert.setMessageText('Find & Replace Colour'); 66 | 67 | var colourChoices = getColourChoices(originalFill, originalBorder, originalShadow); 68 | 69 | if (colourChoices.length) { 70 | if (colourChoices.length == 1) { 71 | // Only one possibility 72 | colour = getColour(selectedLayer, colourChoices[0]); 73 | alert.setInformativeText('You will be replacing the ' + colourChoices[0] + ' colour (#' + colour.hexValue() + ') of this layer.'); 74 | } else { 75 | // Allow user to choose 76 | alert.setInformativeText('Select the property containing the colour to replace, enter a replacement colour, and choose which properties to update.'); 77 | 78 | var choosePropertySelect = createSelect(colourChoices, 0); 79 | 80 | alert.addAccessoryView(choosePropertySelect); 81 | } 82 | } 83 | 84 | alert.addTextLabelWithValue('New colour (Hex or Hue if Hue selected)'); 85 | alert.addTextFieldWithValue('#'); 86 | 87 | alert.addTextLabelWithValue('What should we update on other layers?'); 88 | 89 | var propertyOptions = ['All', 'Fill', 'Border', 'Text', 'Shadow']; 90 | var propertySelect = createSelect(propertyOptions, 0); 91 | alert.addAccessoryView(propertySelect); 92 | 93 | alert.addTextLabelWithValue('Would you like to replace the Colour, or the Hue?'); 94 | 95 | var colourOptions = ['Colour', 'Hue']; 96 | var colourSelect = createSelect(colourOptions, 0); 97 | alert.addAccessoryView(colourSelect); 98 | 99 | alert.addButtonWithTitle('OK'); 100 | alert.addButtonWithTitle('Cancel'); 101 | 102 | return alert; 103 | } 104 | 105 | /** 106 | * Retrieve an array of colour choices given the selected layer's state 107 | * @param {MSStyleFill} originalFill The layer's original fill 108 | * @param {MSStyleBorder} originalBorder The layer's original border 109 | * @param {MSStyleShadow} originalShadow The layer's original shadow 110 | * @return {array} The possible colour choices 111 | */ 112 | function getColourChoices(originalFill, originalBorder, originalShadow) { 113 | var colourChoices = []; 114 | 115 | if (selectedLayerType == 'MSShapeGroup') { 116 | // Shape layer 117 | if (originalFill != undefined) { 118 | colourChoices.push('Fill'); 119 | } 120 | 121 | if (originalBorder != undefined) { 122 | colourChoices.push('Border'); 123 | } 124 | } else { 125 | // Text layers always have a colour 126 | colourChoices.push('Text'); 127 | } 128 | 129 | // Shadow applies to both types of layer 130 | if (originalShadow != undefined) { 131 | colourChoices.push('Shadow'); 132 | } 133 | 134 | return colourChoices; 135 | } 136 | 137 | /** 138 | * Create Select Box for dialog windows 139 | * @param {Array} options Options for the select 140 | * @param {Int} selectedItemIndex Default selected item 141 | * @return {NSComboBox} Complete select box 142 | */ 143 | function createSelect(options, selectedItemIndex) { 144 | selectedItemIndex = selectedItemIndex || 0; 145 | 146 | var select = NSComboBox.alloc().initWithFrame(NSMakeRect(0,0,200,25)); 147 | select.i18nObjectValues = options; 148 | select.setEditable(false); 149 | select.addItemsWithObjectValues(options); 150 | select.selectItemAtIndex(selectedItemIndex); 151 | 152 | return select; 153 | } 154 | 155 | /** 156 | * Get the requested style (fill, border, text) colour of the given layer 157 | * @param {MSLayer} layer The layer whose colour to get 158 | * @param {Object} layerProperty The style colour to fetch 159 | * @return {MSColor} The selected layer colour 160 | */ 161 | function getColour(layer, layerProperty) { 162 | var colour; 163 | var fill; 164 | var border; 165 | var shadow; 166 | 167 | if (layerProperty == 'Text') { 168 | colour = layer.textColor(); 169 | 170 | // If the text layer also has a fill, use this as the primary colour 171 | fill = layer.style().fills().firstObject(); 172 | 173 | if (fill != undefined && fill.isEnabled()) { 174 | colour = fill.color(); 175 | } 176 | } else if (layerProperty == 'Fill') { 177 | fill = layer.style().fills().firstObject(); 178 | 179 | if (fill != undefined && fill.isEnabled()) { 180 | colour = fill.color(); 181 | } 182 | } else if (layerProperty == 'Border') { 183 | border = layer.style().borders().firstObject(); 184 | 185 | if (border != undefined && border.isEnabled()) { 186 | colour = border.color(); 187 | } 188 | } else if (layerProperty == 'Shadow') { 189 | shadow = layer.style().shadows().firstObject(); 190 | 191 | if (shadow != undefined && shadow.isEnabled()) { 192 | colour = shadow.color(); 193 | } 194 | } 195 | 196 | if (colour != undefined) { 197 | colour = colour.immutableModelObject(); 198 | } 199 | 200 | return colour; 201 | } 202 | 203 | /** 204 | * If the selected layer is a group, get its first child, and return a single layer 205 | * @param {NSLayer} selectedLayer The layer selected at run time 206 | * @return {NSLayer} The layer selected, or the first child if a group 207 | */ 208 | function getSelectedLayer(selectedLayer) { 209 | var selectedLayerType = selectedLayer.class(); 210 | 211 | if (selectedLayerType == MSLayerGroup) { 212 | return getSelectedLayer(selectedLayer.layers().firstObject()); 213 | } else { 214 | return selectedLayer; 215 | } 216 | } 217 | 218 | /** 219 | * Collect user input from alert window 220 | * @param {COSAlertWindow} alert The alert window 221 | * @param {Int} responseCode Alert window response code 222 | * @param {String} selectedLayerType Class of the originally selected layer 223 | * @param {MSStyleFill} originalFill The layer's original fill 224 | * @param {MSStyleBorder} originalBorder The layer's original border 225 | * @param {MSStyleShadow} originalShadow The layer's original shadow 226 | * @return {Object} Alert window results 227 | */ 228 | function handleAlertResponse(alert, responseCode, selectedLayerType, originalFill, originalBorder, originalShadow) { 229 | if (responseCode == "1000") { 230 | var colourChoices = getColourChoices(originalFill, originalBorder, originalShadow); 231 | 232 | if (colourChoices.length == 1) { 233 | // 0 = Label 234 | // 1 = Text Input 235 | // 2 = Label 236 | // 3 = Property Select 237 | // 4 = Label 238 | // 5 = Hue Select 239 | return { 240 | layerProperty : colourChoices[0], 241 | colour : alert.viewAtIndex(1).stringValue(), 242 | replacementProperty : alert.viewAtIndex(3).i18nObjectValues[alert.viewAtIndex(3).indexOfSelectedItem()], 243 | replaceHue : alert.viewAtIndex(5).i18nObjectValues[alert.viewAtIndex(5).indexOfSelectedItem()], 244 | }; 245 | } else { 246 | // 0 = Property Select 247 | // 1 = Label 248 | // 2 = Text Input 249 | // 3 = Label 250 | // 4 = Property Select 251 | // 5 = Label 252 | // 6 = Hue Select 253 | return { 254 | layerProperty : alert.viewAtIndex(0).i18nObjectValues[alert.viewAtIndex(0).indexOfSelectedItem()], 255 | colour : alert.viewAtIndex(2).stringValue(), 256 | replacementProperty : alert.viewAtIndex(4).i18nObjectValues[alert.viewAtIndex(4).indexOfSelectedItem()], 257 | replaceHue : alert.viewAtIndex(6).i18nObjectValues[alert.viewAtIndex(6).indexOfSelectedItem()], 258 | }; 259 | } 260 | } 261 | 262 | return null; 263 | } 264 | 265 | /** 266 | * Check if a layer's colour matches the colour of the originally selected layer 267 | * @param {MSLayer} layer The layer whose colour we are comparing 268 | * @return null 269 | */ 270 | function checkLayer(layer, options, originalColour) { 271 | var layerType = layer.class(); 272 | var replacementProperty = options.replacementProperty; 273 | var replaceHue = options.replaceHue == 'Hue'; 274 | 275 | if (layerType == MSLayerGroup) { 276 | // Apply to all layers in a group 277 | for (var i = 0; i < layer.layers().count(); i++) { 278 | checkLayer(layer.layers().objectAtIndex(i), options, originalColour); 279 | } 280 | } else { 281 | if (layerType == MSShapeGroup) { 282 | // Set fill 283 | if (replacementProperty == 'Fill' || replacementProperty == 'All') { 284 | setColour(layer, layerType, 'Fill', replacementColour, originalColour, replaceHue); 285 | } 286 | 287 | // Set border 288 | if (replacementProperty == 'Border' || replacementProperty == 'All') { 289 | setColour(layer, layerType, 'Border', replacementColour, originalColour, replaceHue); 290 | } 291 | } else if (layerType == MSTextLayer && (replacementProperty == 'Text' || replacementProperty == 'All')) { 292 | // Set text colour 293 | setColour(layer, layerType, 'Text', replacementColour, originalColour, replaceHue); 294 | } 295 | 296 | if (layerType != MSSliceLayer && (replacementProperty == 'Shadow' || replacementProperty == 'All')) { 297 | // Set shadow 298 | setColour(layer, layerType, 'Shadow', replacementColour, originalColour, replaceHue); 299 | } 300 | } 301 | } 302 | 303 | /** 304 | * Convert hex string into RGBA 305 | * @param {string} hexColor Hex Colour (with or without #) 306 | * @return {object} Converted RGB values. 307 | */ 308 | function hexToRgb(hex) { 309 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 310 | 311 | if (result) { 312 | result = { 313 | r : parseInt(result[1], 16), 314 | g : parseInt(result[2], 16), 315 | b : parseInt(result[3], 16), 316 | }; 317 | } else { 318 | result = null; 319 | } 320 | 321 | return result; 322 | } 323 | 324 | /** 325 | * Set the fill or text colour of a layer to the newly specified colour 326 | * @param {MSLayer} layer The layer whose colour to set 327 | * @param {MSLayerType} layerType The type of layer 328 | * @param {string} replacementProperty The property to replace 329 | * @param {string} replacementColour The new colour to use 330 | * @param {string} originalColour The original colour to replace 331 | * @param {string} replaceHue Whether to replace Hue, rather than colour 332 | */ 333 | function setColour(layer, layerType, replacementProperty, replacementColour, originalColour, replaceHue) { 334 | var layerColour = getColour(layer, replacementProperty); 335 | var replacementIsHue = false; 336 | var hue; 337 | 338 | if (replacementColour.indexOf('#') == -1) { 339 | // Replacement is a hue value from 0 to 360 340 | replacementIsHue = true; 341 | } else { 342 | replacementColour = hexToRgb(replacementColour); 343 | } 344 | 345 | if (layerColour != undefined && replacementColour != null) { 346 | var hexColour = '#' + layerColour.hexValue(); 347 | 348 | // Convert to MSColor 349 | if (replacementIsHue) { 350 | hue = replacementColour / 360; 351 | } else { 352 | var replacementRGB = MSColor.colorWithRed_green_blue_alpha(replacementColour.r / 255, replacementColour.g / 255, replacementColour.b / 255, 1.0); 353 | hue = replacementRGB.hue(); 354 | } 355 | 356 | if ((replaceHue && matchingHue(hexColour, originalColour)) || hexColour == originalColour) { 357 | // Matching item to update 358 | if (layerType == MSShapeGroup) { 359 | // Shape 360 | if (replacementProperty == 'Fill') { 361 | var fill = layer.style().fills().firstObject(); 362 | 363 | if (fill != undefined) { 364 | if (replaceHue) { 365 | log(getHueAdjustedColour(fill.color(), hue)); 366 | fill.color = getHueAdjustedColour(fill.color(), hue); 367 | } else { 368 | fill.color = MSColor.colorWithRed_green_blue_alpha(replacementColour.r / 255, replacementColour.g / 255, replacementColour.b / 255, 1.0); 369 | } 370 | } 371 | } 372 | 373 | if (replacementProperty == 'Border') { 374 | var border = layer.style().borders().firstObject(); 375 | 376 | if (border != undefined) { 377 | if (replaceHue) { 378 | border.color = getHueAdjustedColour(border.color(), hue); 379 | } else { 380 | border.color = MSColor.colorWithRed_green_blue_alpha(replacementColour.r / 255, replacementColour.g / 255, replacementColour.b / 255, 1.0); 381 | } 382 | } 383 | } 384 | } else { 385 | if (replaceHue) { 386 | layer.textColor = getHueAdjustedColour(layer.textColor(), hue); 387 | } else { 388 | layer.textColor = MSColor.colorWithRed_green_blue_alpha(replacementColour.r / 255, replacementColour.g / 255, replacementColour.b / 255, 1.0); 389 | } 390 | } 391 | 392 | // Text shadow 393 | if (replacementProperty == 'Shadow') { 394 | var shadow = layer.style().shadows().firstObject(); 395 | 396 | if (shadow != undefined) { 397 | if (replaceHue) { 398 | shadow.color = getHueAdjustedColour(shadow.color(), hue); 399 | } else { 400 | shadow.color = MSColor.colorWithRed_green_blue_alpha(replacementColour.r / 255, replacementColour.g / 255, replacementColour.b / 255, 1.0); 401 | } 402 | } 403 | } 404 | } 405 | } 406 | } 407 | 408 | /** 409 | * Determine if two colours share the same hue, within a given threshold 410 | * @param {string} colourOne Hex value for colour one 411 | * @param {string} colourTwo Hex value for colour two 412 | * @return {Boolean} Whether the hue of the two colours matches 413 | */ 414 | function matchingHue(colourOne, colourTwo) { 415 | var colourOneRGB = hexToRgb(colourOne); 416 | var colourTwoRGB = hexToRgb(colourTwo); 417 | 418 | // Convert to MSColors 419 | colourOne = MSColor.colorWithRed_green_blue_alpha(colourOneRGB.r / 255, colourOneRGB.g / 255, colourOneRGB.b / 255, 1.0); 420 | colourTwo = MSColor.colorWithRed_green_blue_alpha(colourTwoRGB.r / 255, colourTwoRGB.g / 255, colourTwoRGB.b / 255, 1.0); 421 | 422 | var threshold = 0.005; 423 | var difference = colourOne.hue() - colourTwo.hue(); 424 | 425 | if (Math.abs(difference) < Math.abs(threshold)) { 426 | return true; 427 | } 428 | 429 | return false; 430 | } 431 | 432 | /** 433 | * Convert a colour with a new hue 434 | * @param {MSColor} layerColour Current layer colour 435 | * @param {object} hue New Hue 436 | * @return {MSColor} Hue adjusted colour 437 | */ 438 | function getHueAdjustedColour(layerColour, hue) { 439 | return MSColor.colorWithHue_saturation_brightness_alpha(hue, layerColour.saturation(), layerColour.brightness(), layerColour.alpha()); 440 | } 441 | }; 442 | --------------------------------------------------------------------------------