├── 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 | 
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 |
19 |
20 |
21 |
22 | Properties
23 |
24 | Direction
25 |
26 |
40 |
41 |
42 |
43 | Rotation
44 |
45 |
59 |
60 |
61 |
62 | Spacing
63 |
64 |
78 |
79 |
80 |
81 | Opacity
82 |
83 |
97 |
98 | Apply
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 };
--------------------------------------------------------------------------------