├── README.md └── mosaic.sketchplugin └── Contents ├── Resources └── web-ui │ ├── index.html │ ├── script.js │ └── style.css └── Sketch ├── MochaJSDelegate.js ├── constants.js ├── index.js ├── manifest.json ├── mosaic.js └── ui.js /README.md: -------------------------------------------------------------------------------- 1 | # Mosaic 2 | 3 | ![Image showing plugin window and example patterns](https://cloud.netlifyusercontent.com/assets/344dbf88-fdf9-42bb-adb4-46f01eedd629/5cd14d15-fa84-48ee-b2d4-24f3aee88efc/ui-pattern-examples.png) 4 | 5 | Mosaic is a Plugin for Sketch that helps you arrange copies of layers in amazing patterns. It was developed as an example plugin for [How To Build A Sketch Plugin With JavaScript, HTML And CSS](https://www.smashingmagazine.com/2019/07/build-sketch-plugin-javascript-html-css-part-1/). 6 | -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Resources/web-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Mosaic

11 | 12 |

Number of Copies

13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 | 22 |

Properties

23 | 24 |

Direction

25 | 26 |
27 | 33 | 39 |
40 | 41 |
42 | 43 |

Rotation

44 | 45 |
46 | 52 | 58 |
59 | 60 |
61 | 62 |

Spacing

63 | 64 |
65 | 71 | 77 |
78 | 79 |
80 | 81 |

Opacity

82 | 83 |
84 | 90 | 96 |
97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Resources/web-ui/script.js: -------------------------------------------------------------------------------- 1 | function getOptions(formId){ 2 | const form = document.getElementById(formId); 3 | const formData = new FormData(form); 4 | 5 | const options = {}; 6 | 7 | for(let keyValuePair of formData){ 8 | options[keyValuePair[0]] = parseFloat(keyValuePair[1]); 9 | } 10 | 11 | return options; 12 | }; 13 | 14 | function apply(){ 15 | // Grab options 16 | 17 | const numberOfCopies = parseFloat(document.getElementById("number-of-copies").value, 10); 18 | const properties = { 19 | direction: getOptions("direction"), 20 | rotation: getOptions("rotation"), 21 | spacing: getOptions("spacing"), 22 | opacity: getOptions("opacity") 23 | }; 24 | 25 | const startingOptions = {}; 26 | const stepOptions = {}; 27 | 28 | for(const key in properties){ 29 | startingOptions[key] = properties[key].initial; 30 | stepOptions[key] = properties[key].increment; 31 | } 32 | 33 | const message = { numberOfCopies, startingOptions, stepOptions }; 34 | 35 | // Send options to plugin 36 | 37 | if(window.webkit && window.webkit.messageHandlers.sketchPlugin){ 38 | window.webkit.messageHandlers.sketchPlugin.postMessage(JSON.stringify(message)); 39 | } else { 40 | console.error("Failed to send options - could not to find 'sketchPlugin' message handler. Is every thing set up properly for messaging?"); 41 | } 42 | }; 43 | 44 | document.addEventListener("DOMContentLoaded", () => { 45 | // Set up apply to trigger on button press 46 | 47 | document.getElementById("apply-btn").addEventListener("click", () => { 48 | apply(); 49 | }); 50 | 51 | // Add ENTER key shortcut 52 | 53 | document.body.addEventListener("keyup", e => { 54 | if(e.keyCode !== 13) return; 55 | 56 | apply(); 57 | }); 58 | }); -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Resources/web-ui/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | --control-background: #FFF; 3 | --control-border: #D4D4D4; 4 | } 5 | 6 | body { 7 | background: #F2F2F2; 8 | font-family: -apple-system; 9 | margin: 10px; 10 | -webkit-user-select: none; 11 | -moz-user-select: none; 12 | -ms-user-select: none; 13 | user-select: none; 14 | cursor: default; 15 | } 16 | 17 | h1, h2 { 18 | margin: 0; 19 | margin-bottom: 10px; 20 | font-size: 12px; 21 | } 22 | 23 | h1 { 24 | color: #767676; 25 | font-weight: bold; 26 | text-transform: uppercase; 27 | } 28 | 29 | .textfield { 30 | display: grid; 31 | grid-template-columns: 1fr -webkit-min-content; 32 | grid-template-columns: 1fr min-content; 33 | align-items: center; 34 | height: 20px; 35 | padding: 0 5px; 36 | background: var(--control-background); 37 | border: 1px solid var(--control-border); 38 | border-radius: 4px; 39 | } 40 | .textfield > input { 41 | background: transparent; 42 | border: none; 43 | padding: 0; 44 | font-size: 11px; 45 | min-width: 0; 46 | grid-column: 1 / 2; 47 | } 48 | .textfield::after { 49 | display: inline; 50 | content: attr(data-unit); 51 | color: #828282; 52 | font-size: 11px; 53 | grid-column: 2 / 3; 54 | grid-row: 1 / 2; 55 | } 56 | .textfield:not(:hover) > input::-webkit-inner-spin-button, .textfield:not(:hover) > input::-webkit-outer-spin-button { 57 | display: none; 58 | } 59 | 60 | .textfield-row { 61 | display: grid; 62 | grid-template-columns: 1fr 1fr; 63 | grid-gap: 10px; 64 | } 65 | 66 | .textfield-label-content { 67 | font-size: 11px; 68 | text-align: center; 69 | margin-top: 2px; 70 | } 71 | 72 | hr { 73 | border: none; 74 | background: #D9D9D9; 75 | height: 1px; 76 | margin-top: 10px; 77 | } 78 | 79 | #apply-btn { 80 | display: block; 81 | margin: 20px auto 0 auto; 82 | } 83 | -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Sketch/MochaJSDelegate.js: -------------------------------------------------------------------------------- 1 | // 2 | // MochaJSDelegate.js 3 | // MochaJSDelegate 4 | // 5 | // Created by Matt Curtis 6 | // Copyright (c) 2015. All rights reserved. 7 | // 8 | 9 | var MochaJSDelegate = function(selectorHandlerDict){ 10 | var uniqueClassName = "MochaJSDelegate_DynamicClass_" + NSUUID.UUID().UUIDString(); 11 | 12 | var delegateClassDesc = MOClassDescription.allocateDescriptionForClassWithName_superclass_(uniqueClassName, NSObject); 13 | 14 | delegateClassDesc.registerClass(); 15 | 16 | // Handler storage 17 | 18 | var handlers = {}; 19 | 20 | // Define interface 21 | 22 | this.setHandlerForSelector = function(selectorString, func){ 23 | var handlerHasBeenSet = (selectorString in handlers); 24 | var selector = NSSelectorFromString(selectorString); 25 | 26 | handlers[selectorString] = func; 27 | 28 | if(!handlerHasBeenSet){ 29 | /* 30 | For some reason, Mocha acts weird about arguments: 31 | https://github.com/logancollins/Mocha/issues/28 32 | 33 | We have to basically create a dynamic handler with a likewise dynamic number of predefined arguments. 34 | */ 35 | 36 | var dynamicHandler = function(){ 37 | var functionToCall = handlers[selectorString]; 38 | 39 | if(!functionToCall) return; 40 | 41 | return functionToCall.apply(delegateClassDesc, arguments); 42 | }; 43 | 44 | var args = [], regex = /:/g; 45 | while(match = regex.exec(selectorString)) args.push("arg"+args.length); 46 | 47 | dynamicFunction = eval("(function("+args.join(",")+"){ return dynamicHandler.apply(this, arguments); })"); 48 | 49 | delegateClassDesc.addInstanceMethodWithSelector_function_(selector, dynamicFunction); 50 | } 51 | }; 52 | 53 | this.removeHandlerForSelector = function(selectorString){ 54 | delete handlers[selectorString]; 55 | }; 56 | 57 | this.getHandlerForSelector = function(selectorString){ 58 | return handlers[selectorString]; 59 | }; 60 | 61 | this.getAllHandlers = function(){ 62 | return handlers; 63 | }; 64 | 65 | this.getClass = function(){ 66 | return NSClassFromString(uniqueClassName); 67 | }; 68 | 69 | this.getClassInstance = function(){ 70 | return NSClassFromString(uniqueClassName).new(); 71 | }; 72 | 73 | // Conveience 74 | 75 | if(typeof selectorHandlerDict == "object"){ 76 | for(var selectorString in selectorHandlerDict){ 77 | this.setHandlerForSelector(selectorString, selectorHandlerDict[selectorString]); 78 | } 79 | } 80 | }; 81 | 82 | module.exports = MochaJSDelegate; -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Sketch/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isSpecialGroupKey: "is-duuuplicate-group" 3 | }; -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Sketch/index.js: -------------------------------------------------------------------------------- 1 | const UI = require("./ui"); 2 | const mosaic = require("./mosaic"); 3 | const Async = require("sketch/async"); 4 | 5 | // Sketch Handlers 6 | 7 | var fiber; 8 | 9 | function onRun(context){ 10 | if(!fiber){ 11 | fiber = Async.createFiber(); 12 | fiber.onCleanup(() => { 13 | UI.cleanup(); 14 | }); 15 | } 16 | 17 | UI.loadAndShow(context.scriptURL, options => { 18 | mosaic(options); 19 | }); 20 | }; -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Sketch/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mosaic", 3 | "description": "Mosaic helps you arrange copies of layers in amazing patterns!", 4 | 5 | "author": "Matt Curtis", 6 | "authorEmail": "", 7 | 8 | "identifier": "com.matt-curtis.mosaic", 9 | "version": "1.0", 10 | 11 | "disableCocoaScriptPreprocessor": true, 12 | 13 | "commands": [ 14 | { 15 | "script": "index.js", 16 | "name": "Mosaic", 17 | "handlers": { 18 | "run": "onRun" 19 | }, 20 | "identifier": "open" 21 | } 22 | ], 23 | 24 | "menu": { 25 | "items": [ 26 | "open" 27 | ], 28 | "isRoot": true 29 | } 30 | } -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Sketch/mosaic.js: -------------------------------------------------------------------------------- 1 | const { Document, Group } = require("sketch/dom"); 2 | const Settings = require("sketch/settings"); 3 | const UI = require("sketch/ui"); 4 | const Constants = require("./constants"); 5 | 6 | function findOrMakeSpecialGroupIfNeeded(layer){ 7 | // Loop up through the parent hierarchy, looking for a special group 8 | 9 | var layerToCheck = layer; 10 | 11 | while(layerToCheck){ 12 | let isSpecialGroup = !!Settings.layerSettingForKey(layerToCheck, Constants.isSpecialGroupKey); 13 | 14 | if(isSpecialGroup) return layerToCheck; 15 | 16 | layerToCheck = layerToCheck.parent; 17 | } 18 | 19 | // Group 20 | 21 | const destinationParent = layer.parent; 22 | 23 | layer.remove(); // remove layer from it's existing parent before adding to group 24 | 25 | const group = new Group({ 26 | name: "Mosaic", 27 | layers: [ layer ], 28 | parent: destinationParent 29 | }); 30 | 31 | Settings.setLayerSettingForKey(group, Constants.isSpecialGroupKey, true); 32 | 33 | return group; 34 | }; 35 | 36 | function configureLayer(layer, options, shouldAdjustSpacing){ 37 | const { opacity, rotation, direction, spacing } = options; 38 | 39 | layer.style.opacity = opacity / 100; 40 | layer.sketchObject.rotation = -rotation; 41 | 42 | if(shouldAdjustSpacing){ 43 | const directionAsRadians = direction * (Math.PI / 180); 44 | const vector = { 45 | x: Math.cos(directionAsRadians), 46 | y: Math.sin(directionAsRadians) 47 | }; 48 | 49 | layer.frame.x += vector.x * spacing; 50 | layer.frame.y += vector.y * spacing; 51 | } 52 | }; 53 | 54 | function stepOptionsBy(start, step){ 55 | const newOptions = {}; 56 | 57 | for(let key in start){ 58 | newOptions[key] = start[key] + step[key]; 59 | } 60 | 61 | return newOptions; 62 | }; 63 | 64 | function mosaic(options){ 65 | const document = Document.getSelectedDocument(); 66 | 67 | // Safety check: 68 | 69 | if(!document){ 70 | UI.alert("Mosaic", "⚠️ Please select/focus a document."); 71 | 72 | return; 73 | } 74 | 75 | // Safety check: 76 | 77 | const selectedLayer = document.selectedLayers.layers[0]; 78 | 79 | if(!selectedLayer){ 80 | UI.alert("Mosaic", "⚠️ Please select a layer to duplicate."); 81 | 82 | return; 83 | } 84 | 85 | // Group selection if needed 86 | 87 | const group = findOrMakeSpecialGroupIfNeeded(selectedLayer); 88 | 89 | // Destructure options: 90 | 91 | var { numberOfCopies, startingOptions, stepOptions } = options; 92 | 93 | numberOfCopies = Math.max(1, numberOfCopies); 94 | 95 | // Remove all layers except the first: 96 | 97 | while(group.layers.length > 1){ 98 | group.layers[group.layers.length - 1].remove(); 99 | } 100 | 101 | // Configure template layer 102 | 103 | var layer = group.layers[0]; 104 | 105 | configureLayer(layer, startingOptions); 106 | 107 | // Create duplicates until we've met the desired number 108 | // Configure each duplicate using the desired options 109 | 110 | var currentOptions = stepOptionsBy(startingOptions, stepOptions); 111 | 112 | for(let i = 0; i < (numberOfCopies - 1); i++){ 113 | let duplicateLayer = layer.duplicate(); 114 | 115 | configureLayer(duplicateLayer, currentOptions, true); 116 | 117 | currentOptions = stepOptionsBy(currentOptions, stepOptions); 118 | layer = duplicateLayer; 119 | } 120 | 121 | // Fit group to duplicates 122 | 123 | group.adjustToFit(); 124 | 125 | // Set selection to the group 126 | 127 | document.selectedLayers.clear(); 128 | group.selected = true; 129 | }; 130 | 131 | module.exports = mosaic; -------------------------------------------------------------------------------- /mosaic.sketchplugin/Contents/Sketch/ui.js: -------------------------------------------------------------------------------- 1 | const MochaJSDelegate = require("./MochaJSDelegate"); 2 | 3 | var _window; 4 | 5 | // Private 6 | 7 | function createWebView(pageURL, onApplyMessage, onLoadFinish){ 8 | const webView = WKWebView.alloc().init(); 9 | 10 | // Create delegate 11 | 12 | const delegate = new MochaJSDelegate({ 13 | "webView:didFinishNavigation:": (webView, navigation) => { 14 | onLoadFinish(); 15 | }, 16 | "userContentController:didReceiveScriptMessage:": (_, wkMessage) => { 17 | const message = JSON.parse(wkMessage.body()); 18 | 19 | onApplyMessage(message); 20 | } 21 | }).getClassInstance(); 22 | 23 | // Set load complete handler 24 | 25 | webView.navigationDelegate = delegate; 26 | 27 | // Set handler for messages from script 28 | 29 | const userContentController = webView.configuration().userContentController(); 30 | 31 | userContentController.addScriptMessageHandler_name(delegate, "sketchPlugin"); 32 | 33 | // Load page into web view 34 | 35 | webView.loadFileURL_allowingReadAccessToURL(pageURL, pageURL.URLByDeletingLastPathComponent()); 36 | 37 | return webView; 38 | }; 39 | 40 | function createWindow(){ 41 | const window = NSPanel.alloc().initWithContentRect_styleMask_backing_defer( 42 | NSMakeRect(0, 0, 145, 500), 43 | NSWindowStyleMaskClosable | NSWindowStyleMaskTitled | NSWindowStyleMaskResizable, 44 | NSBackingStoreBuffered, 45 | false 46 | ); 47 | 48 | window.becomesKeyOnlyIfNeeded = true; 49 | window.floatingPanel = true; 50 | 51 | window.frameAutosaveName = "mosaic-panel-frame"; 52 | 53 | window.minSize = window.frame().size; 54 | window.maxSize = window.frame().size; 55 | 56 | window.releasedWhenClosed = false; 57 | 58 | window.standardWindowButton(NSWindowZoomButton).hidden = true; 59 | window.standardWindowButton(NSWindowMiniaturizeButton).hidden = true; 60 | 61 | window.titlebarAppearsTransparent = true; 62 | 63 | window.backgroundColor = NSColor.colorWithRed_green_blue_alpha(0.95, 0.95, 0.95, 1.0); 64 | 65 | return window; 66 | }; 67 | 68 | function showWindow(window){ 69 | window.makeKeyAndOrderFront(nil); 70 | }; 71 | 72 | // Public 73 | 74 | function loadAndShow(baseURL, onApplyMessage){ 75 | if(_window){ 76 | showWindow(_window); 77 | 78 | return; 79 | } 80 | 81 | const pageURL = baseURL 82 | .URLByDeletingLastPathComponent() 83 | .URLByAppendingPathComponent("../Resources/web-ui/index.html"); 84 | 85 | const window = createWindow(); 86 | const webView = createWebView(pageURL, onApplyMessage, () => { 87 | showWindow(window); 88 | }); 89 | 90 | window.contentView = webView; 91 | 92 | _window = window; 93 | }; 94 | 95 | function cleanup(){ 96 | if(_window){ 97 | _window.orderOut(nil); 98 | _window = null; 99 | } 100 | }; 101 | 102 | // Export 103 | 104 | module.exports = { loadAndShow, cleanup }; --------------------------------------------------------------------------------