├── assets ├── ic-logo.png ├── ic-logo@2x.png ├── ic-duplicate-left.png ├── ic-duplicate-above.png ├── ic-duplicate-below.png ├── ic-duplicate-left@2x.png ├── ic-duplicate-right.png ├── ic-duplicate-above@2x.png ├── ic-duplicate-below@2x.png ├── ic-duplicate-right@2x.png ├── ic-duplicator-settings.png └── ic-duplicator-settings@2x.png ├── docs ├── hero-logo.png ├── settings-dialog.png ├── duplicating-layers.gif ├── remembering-offsets.gif ├── runner-installation.png ├── repeaters-custom-offsets.gif ├── repeaters-adjusted-offsets.gif ├── duplicating-artboards-and-symbols.gif └── duplicating-multiple-layers-at-once.gif ├── Duplicator.sketchplugin └── Contents │ ├── Resources │ ├── ic-logo.png │ ├── ic-logo@2x.png │ ├── ic-duplicate-above.png │ ├── ic-duplicate-below.png │ ├── ic-duplicate-left.png │ ├── ic-duplicate-right.png │ ├── ic-duplicate-above@2x.png │ ├── ic-duplicate-below@2x.png │ ├── ic-duplicate-left@2x.png │ ├── ic-duplicate-right@2x.png │ ├── ic-duplicator-settings.png │ └── ic-duplicator-settings@2x.png │ └── Sketch │ └── manifest.json ├── .gitignore ├── src ├── thread-storage.js ├── utils.js ├── constants.js ├── plugin.js ├── manifest.json ├── settings.js ├── prop-editor.js └── duplicate.js ├── package.json ├── LICENSE ├── .appcast.xml └── README.md /assets/ic-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-logo.png -------------------------------------------------------------------------------- /docs/hero-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/hero-logo.png -------------------------------------------------------------------------------- /assets/ic-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-logo@2x.png -------------------------------------------------------------------------------- /docs/settings-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/settings-dialog.png -------------------------------------------------------------------------------- /assets/ic-duplicate-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-left.png -------------------------------------------------------------------------------- /docs/duplicating-layers.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/duplicating-layers.gif -------------------------------------------------------------------------------- /docs/remembering-offsets.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/remembering-offsets.gif -------------------------------------------------------------------------------- /docs/runner-installation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/runner-installation.png -------------------------------------------------------------------------------- /assets/ic-duplicate-above.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-above.png -------------------------------------------------------------------------------- /assets/ic-duplicate-below.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-below.png -------------------------------------------------------------------------------- /assets/ic-duplicate-left@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-left@2x.png -------------------------------------------------------------------------------- /assets/ic-duplicate-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-right.png -------------------------------------------------------------------------------- /assets/ic-duplicate-above@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-above@2x.png -------------------------------------------------------------------------------- /assets/ic-duplicate-below@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-below@2x.png -------------------------------------------------------------------------------- /assets/ic-duplicate-right@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicate-right@2x.png -------------------------------------------------------------------------------- /assets/ic-duplicator-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicator-settings.png -------------------------------------------------------------------------------- /docs/repeaters-custom-offsets.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/repeaters-custom-offsets.gif -------------------------------------------------------------------------------- /assets/ic-duplicator-settings@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/assets/ic-duplicator-settings@2x.png -------------------------------------------------------------------------------- /docs/repeaters-adjusted-offsets.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/repeaters-adjusted-offsets.gif -------------------------------------------------------------------------------- /docs/duplicating-artboards-and-symbols.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/duplicating-artboards-and-symbols.gif -------------------------------------------------------------------------------- /docs/duplicating-multiple-layers-at-once.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/docs/duplicating-multiple-layers-at-once.gif -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-logo.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-logo@2x.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-above.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-above.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-below.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-below.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-left.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-right.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artefacts 2 | Duplicator.sketchplugin.zip 3 | 4 | # npm 5 | node_modules 6 | .npm 7 | npm-debug.log 8 | 9 | # mac 10 | .DS_Store 11 | 12 | # IDE 13 | .idea 14 | -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-above@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-above@2x.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-below@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-below@2x.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-left@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-left@2x.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicate-right@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicate-right@2x.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicator-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicator-settings.png -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Resources/ic-duplicator-settings@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/turbobabr/duplicator/HEAD/Duplicator.sketchplugin/Contents/Resources/ic-duplicator-settings@2x.png -------------------------------------------------------------------------------- /src/thread-storage.js: -------------------------------------------------------------------------------- 1 | const Storage = { 2 | _storage() { 3 | return NSThread.currentThread().threadDictionary(); 4 | }, 5 | 6 | set(key,value) { 7 | this._storage()[key] = value; 8 | }, 9 | get(key) { 10 | return this._storage()[key]; 11 | }, 12 | exists(key) { 13 | return this.get(key) ? true : false; 14 | }, 15 | remove(key) { 16 | this._storage().removeObjectForKey(key); 17 | } 18 | }; 19 | 20 | export default Storage; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const Utils = {}; 4 | 5 | Utils.normalizeObject = (obj) => { 6 | if(!obj) { 7 | return null; 8 | } 9 | 10 | if(obj.isKindOfClass(NSString)) { 11 | return obj.UTF8String(); 12 | } else if(obj.isKindOfClass(NSValue)) { 13 | return obj + 0; 14 | } else if(obj.isKindOfClass(NSDictionary)) { 15 | return _.fromPairs(_.map(obj,(value,key) => { 16 | return [key,Utils.normalizeObject(value)]; 17 | })); 18 | } else if(obj.isKindOfClass(NSArray)) { 19 | return _.map(obj,(value) => { 20 | return Utils.normalizeObject(value); 21 | }); 22 | } 23 | 24 | return obj; 25 | }; 26 | 27 | Utils.normalize = (obj) => { // Alias for `normalizeObject` 28 | return Utils.normalizeObject(obj); 29 | }; 30 | 31 | export default Utils; -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const Commands = { 4 | Left: 'duplicateLeft', 5 | Right: 'duplicateRight', 6 | Above: 'duplicateAbove', 7 | Below: 'duplicateBelow', 8 | 9 | LeftRepeater: 'duplicateLeftRepeater', 10 | RightRepeater: 'duplicateRightRepeater', 11 | AboveRepeater: 'duplicateAboveRepeater', 12 | BelowRepeater: 'duplicateBelowRepeater', 13 | 14 | Settings: 'settings' 15 | }; 16 | 17 | export const Direction = { 18 | Left: 'left', 19 | Above: 'above', 20 | Right: 'right', 21 | Below: 'below' 22 | }; 23 | 24 | export const InjectionMode = { 25 | Default: 'default', 26 | BeforeSelection: 'beforeSelection', 27 | AfterSelection: 'afterSelection' 28 | }; 29 | 30 | export const PREVIOUS_STENCIL_DESCRIPTOR_KEY = 'com.duplicator.previous.stencil'; 31 | export const PRESERVED_OFFSETS_KEY = 'com.duplicator.preserved.offsets'; 32 | export const DEFAULTS_STORAGE_KEY = 'com.turbobabr.duplicator.defaultSettings'; 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "duplicator", 3 | "version": "2.0.3", 4 | "description": "A handy Sketch plugin for duplicating selected layers or artboards in a specified direction.", 5 | "scripts": { 6 | "build": "skpm-build", 7 | "watch": "skpm-build --watch", 8 | "start": "skpm-build --watch --run", 9 | "postinstall": "npm run build && skpm-link" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/turbobabr/duplicator.git" 14 | }, 15 | "author": "Andrey Shakhmin ", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/turbobabr/duplicator/issues" 19 | }, 20 | "homepage": "https://github.com/turbobabr/duplicator#readme", 21 | "engines": { 22 | "sketch": ">=3.0" 23 | }, 24 | "devDependencies": { 25 | "@skpm/builder": "^0.4.0" 26 | }, 27 | "skpm": { 28 | "name": "Duplicator", 29 | "manifest": "src/manifest.json", 30 | "main": "Duplicator.sketchplugin", 31 | "assets": [ 32 | "assets/**/*" 33 | ] 34 | }, 35 | "dependencies": { 36 | "lodash": "^4.17.11" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Andrey Shakhmin 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. -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Commands, 4 | Direction 5 | } from './constants'; 6 | 7 | import Utils from './utils'; 8 | 9 | import { duplicateOnce, duplicateWithRepeater } from './duplicate'; 10 | import { showSettingsEditor } from './settings'; 11 | 12 | export default function (context) { 13 | 14 | switch(Utils.normalize(context.command.identifier())) { 15 | case Commands.Left: 16 | duplicateOnce(context.selection, Direction.Left); 17 | break; 18 | 19 | case Commands.Right: 20 | duplicateOnce(context.selection, Direction.Right); 21 | break; 22 | 23 | case Commands.Above: 24 | duplicateOnce(context.selection,Direction.Above); 25 | break; 26 | 27 | case Commands.Below: 28 | duplicateOnce(context.selection,Direction.Below); 29 | break; 30 | 31 | case Commands.LeftRepeater: 32 | duplicateWithRepeater(context.selection, Direction.Left); 33 | break; 34 | 35 | case Commands.RightRepeater: 36 | duplicateWithRepeater(context.selection, Direction.Right); 37 | break; 38 | 39 | case Commands.AboveRepeater: 40 | duplicateWithRepeater(context.selection,Direction.Above); 41 | break; 42 | 43 | case Commands.BelowRepeater: 44 | duplicateWithRepeater(context.selection,Direction.Below); 45 | break; 46 | 47 | case Commands.Settings: 48 | showSettingsEditor(); 49 | break; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Duplicator", 3 | "identifier" : "com.turbobabr.sketch.duplicator", 4 | "author" : "Andrey Shakhmin", 5 | "compatibleVersion": 3, 6 | "bundleVersion": 1, 7 | "icon": "ic-logo.png", 8 | "commands": [ 9 | { 10 | "name": "Duplicate Left", 11 | "identifier": "duplicateLeft", 12 | "shortcut": "control command ←", 13 | "script": "./plugin.js" 14 | }, 15 | { 16 | "name": "Duplicate Right", 17 | "identifier": "duplicateRight", 18 | "shortcut": "control command →", 19 | "script": "./plugin.js" 20 | }, 21 | { 22 | "name": "Duplicate Above", 23 | "identifier": "duplicateAbove", 24 | "shortcut": "control command ↑", 25 | "script": "./plugin.js" 26 | }, 27 | { 28 | "name": "Duplicate Below", 29 | "identifier": "duplicateBelow", 30 | "shortcut": "control command ↓", 31 | "script": "./plugin.js" 32 | }, 33 | { 34 | "name" : "Repeat Left", 35 | "identifier" : "duplicateLeftRepeater", 36 | "shortcut" : "control shift command ←", 37 | "script": "./plugin.js" 38 | }, 39 | { 40 | "name" : "Repeat Right", 41 | "identifier" : "duplicateRightRepeater", 42 | "shortcut" : "control shift command →", 43 | "script": "./plugin.js" 44 | }, 45 | { 46 | "name" : "Repeat Above", 47 | "identifier" : "duplicateAboveRepeater", 48 | "shortcut" : "control shift command ↑", 49 | "script": "./plugin.js" 50 | }, 51 | { 52 | "name" : "Repeat Below", 53 | "identifier" : "duplicateBelowRepeater", 54 | "shortcut" : "control shift command ↓", 55 | "script": "./plugin.js" 56 | }, 57 | { 58 | "name": "Settings...", 59 | "identifier": "settings", 60 | "script": "./plugin.js" 61 | } 62 | ], 63 | "menu": { 64 | "title": "Duplicator", 65 | "items": [ 66 | "duplicateLeft", 67 | "duplicateRight", 68 | "duplicateAbove", 69 | "duplicateBelow", 70 | "-", 71 | "duplicateLeftRepeater", 72 | "duplicateRightRepeater", 73 | "duplicateAboveRepeater", 74 | "duplicateBelowRepeater", 75 | "-", 76 | "settings" 77 | ] 78 | } 79 | } -------------------------------------------------------------------------------- /Duplicator.sketchplugin/Contents/Sketch/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Duplicator", 3 | "identifier": "com.turbobabr.sketch.duplicator", 4 | "author": "Andrey Shakhmin", 5 | "compatibleVersion": 3, 6 | "bundleVersion": 1, 7 | "icon": "ic-logo.png", 8 | "commands": [ 9 | { 10 | "name": "Duplicate Left", 11 | "identifier": "duplicateLeft", 12 | "shortcut": "control command ←", 13 | "script": "plugin.js" 14 | }, 15 | { 16 | "name": "Duplicate Right", 17 | "identifier": "duplicateRight", 18 | "shortcut": "control command →", 19 | "script": "plugin.js" 20 | }, 21 | { 22 | "name": "Duplicate Above", 23 | "identifier": "duplicateAbove", 24 | "shortcut": "control command ↑", 25 | "script": "plugin.js" 26 | }, 27 | { 28 | "name": "Duplicate Below", 29 | "identifier": "duplicateBelow", 30 | "shortcut": "control command ↓", 31 | "script": "plugin.js" 32 | }, 33 | { 34 | "name": "Repeat Left", 35 | "identifier": "duplicateLeftRepeater", 36 | "shortcut": "control shift command ←", 37 | "script": "plugin.js" 38 | }, 39 | { 40 | "name": "Repeat Right", 41 | "identifier": "duplicateRightRepeater", 42 | "shortcut": "control shift command →", 43 | "script": "plugin.js" 44 | }, 45 | { 46 | "name": "Repeat Above", 47 | "identifier": "duplicateAboveRepeater", 48 | "shortcut": "control shift command ↑", 49 | "script": "plugin.js" 50 | }, 51 | { 52 | "name": "Repeat Below", 53 | "identifier": "duplicateBelowRepeater", 54 | "shortcut": "control shift command ↓", 55 | "script": "plugin.js" 56 | }, 57 | { 58 | "name": "Settings...", 59 | "identifier": "settings", 60 | "script": "plugin.js" 61 | } 62 | ], 63 | "menu": { 64 | "title": "Duplicator", 65 | "items": [ 66 | "duplicateLeft", 67 | "duplicateRight", 68 | "duplicateAbove", 69 | "duplicateBelow", 70 | "-", 71 | "duplicateLeftRepeater", 72 | "duplicateRightRepeater", 73 | "duplicateAboveRepeater", 74 | "duplicateBelowRepeater", 75 | "-", 76 | "settings" 77 | ] 78 | }, 79 | "version": "2.0.3", 80 | "description": "A handy Sketch plugin for duplicating selected layers or artboards in a specified direction.", 81 | "homepage": "https://github.com/turbobabr/duplicator#readme", 82 | "disableCocoaScriptPreprocessor": true, 83 | "appcast": "https://raw.githubusercontent.com/turbobabr/duplicator/master/.appcast.xml" 84 | } -------------------------------------------------------------------------------- /.appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Duplicator 5 | https://github.com/turbobabr/duplicator 6 | A handy Sketch plugin for duplicating selected layers or artboards in a specified direction. 7 | en 8 | 9 | Duplicator 2.0.3 10 | 11 | 13 |
  • Sketch 53 support
  • 14 |
  • BUGFIX: Wrong layers ordering for `Before Selection` injection mode
  • 15 |
  • BUGFIX: Added missing icon
  • 16 | 17 | ]]> 18 |
    19 | 20 |
    21 | 22 | Duplicator 2.0.2 23 | 24 | 26 |
  • Sketch 49 support
  • 27 | 28 | ]]> 29 |
    30 | 31 |
    32 | 33 | Duplicator 2.0.1 34 | 35 | 37 |
  • BUGFIX: Nested duplicated layers can't be selected on canvas
  • 38 | 39 | ]]> 40 |
    41 | 42 |
    43 | 44 | Duplicator 2.0.0 45 | 46 | 48 |
  • Artboards and symbols support
  • 49 |
  • Ability to manually adjust offsets between duplicates
  • 50 |
  • Customizable injection mode
  • 51 |
  • Sketch 46 support
  • 52 | 53 | ]]> 54 |
    55 | 56 |
    57 |
    58 |
    59 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | import PropEditorDialog, { PropType, PropEditorType } from './prop-editor'; 2 | import { InjectionMode } from './constants'; 3 | 4 | import { DEFAULTS_STORAGE_KEY } from './constants'; 5 | 6 | const loadDefaultSettings = () => { 7 | let str = NSUserDefaults.standardUserDefaults().stringForKey(DEFAULTS_STORAGE_KEY); 8 | if(!str) { 9 | return {}; 10 | } 11 | 12 | let options = {}; 13 | 14 | try { 15 | options = JSON.parse(str); 16 | } catch(err) { 17 | print("[duplicator]: Error - Can't load default settings!"); 18 | } 19 | 20 | return options; 21 | }; 22 | 23 | const saveDefaultSettings = (options) => { 24 | options = options || {}; 25 | 26 | let str = JSON.stringify(options); 27 | 28 | let userDefaults = NSUserDefaults.standardUserDefaults(); 29 | userDefaults.setObject_forKey(str,DEFAULTS_STORAGE_KEY); 30 | userDefaults.synchronize(); 31 | }; 32 | 33 | 34 | export const defaultSettings = (options = {}) => { 35 | return _.assign({ 36 | defaultOffset: 10, 37 | defaultArtboardOffset: 100, 38 | injectionMode: InjectionMode.AfterSelection 39 | },loadDefaultSettings(),{ ignoreOffsetDelta: false },options); 40 | }; 41 | 42 | 43 | export const showSettingsEditor = () => { 44 | 45 | let props = defaultSettings(); 46 | 47 | let editor = new PropEditorDialog({ 48 | title: 'Duplicator Settings', 49 | description: '`Offset` is a default offset for all types of layers but artboards and symbols. Use `Artboards Offset` field to adjust default spacing for artboards and symbols instead.\n\n`Injection Mode` option controls where in the layer list duplicated layers will be injected.', 50 | icon: 'ic-logo.png', 51 | props: [ 52 | { 53 | name: 'defaultOffset', 54 | type: PropType.Number, 55 | editorType: PropEditorType.TextField, 56 | label: 'Offset:', 57 | value: props.defaultOffset 58 | }, 59 | { 60 | name: 'defaultArtboardOffset', 61 | type: PropType.Number, 62 | editorType: PropEditorType.TextField, 63 | label: 'Artboards Offset:', 64 | value: props.defaultArtboardOffset 65 | }, 66 | { 67 | name: 'injectionMode', 68 | type: PropType.List, 69 | list: [ 70 | { 71 | name: InjectionMode.Default, 72 | label: 'In Place' 73 | }, 74 | { 75 | name: InjectionMode.BeforeSelection, 76 | label: 'Before Selection' 77 | }, 78 | { 79 | name: InjectionMode.AfterSelection, 80 | label: 'After Selection' 81 | } 82 | ], 83 | editorType: PropEditorType.PopupButton, 84 | label: 'Injection Mode:', 85 | value: props.injectionMode 86 | } 87 | ], 88 | buttons: [ 89 | { 90 | title: "Save", 91 | id: "okButton" 92 | }, 93 | { 94 | title: "Cancel", 95 | id: "cancelButton" 96 | } 97 | ] 98 | }); 99 | 100 | editor.show((response,props) => { 101 | if(response != 'okButton') { 102 | return; 103 | } 104 | 105 | // TODO: Entered properties should be validated before saving them! 106 | saveDefaultSettings(props); 107 | }); 108 | }; 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Hero](docs/hero-logo.png?raw=true "Logo") 2 | =========== 3 | 4 | Duplicator is a handy [SketchApp](http://bohemiancoding.com/sketch/) plugin that takes currently selected layers or artboards and copies them once or multiple times in a specified direction. 5 | 6 | ## Install with Sketch Runner 7 | With Sketch Runner, just go to the `install` command and search for `duplicator`. Runner allows you to manage plugins and do much more to speed up your workflow in Sketch. [Download Runner here](http://www.sketchrunner.com). 8 | 9 | ![Sketch Runner screenshot](https://raw.githubusercontent.com/turbobabr/duplicator/master/docs/runner-installation.png) 10 | 11 | ## Manual Installation 12 | 13 | 1. Download [Duplicator.sketchplugin.zip](https://github.com/turbobabr/duplicator/releases/download/v2.0.3/Duplicator.sketchplugin.zip) archive with the plugin. 14 | 2. Reveal plugins folder in finder ('Sketch App Menu' -> 'Plugins' -> 'Manage Plugins...' -> 'Gear Icon' -> 'Show Plugins Folder'). 15 | 3. Delete previously installed version of the plugin (`duplicator` folder or `Duplicator.sketchplugin` bundle) 16 | 4. Un-zip downloaded archive and double-click `Duplicator.sketchplugin` file to install. 17 | 18 | ## Usage 19 | 20 | ### Duplicating layers 21 | 22 | Select any layer and use `command-control + any arrow key` to duplicate it in a corresponding to the arrow key direction. Duplicated layer gets automatically selected, thus it's easy to create several duplicates of the same layer by repeating the command. By default plugin uses `10px` offset for regular layers. Offset could be adjusted in the `Settings` dialog: 23 | 24 | ![Duplicating layers](docs/duplicating-layers.gif?raw=true) 25 | 26 | ### Duplicating multiple layers at once 27 | 28 | It's possible to select several layers that belong to different groups or artboards and duplicate them at once. It's especially useful for grids generation: 29 | 30 | ![Duplicating multiple layers at once](docs/duplicating-multiple-layers-at-once.gif?raw=true) 31 | 32 | ### Duplicating artboards and symbols 33 | 34 | All layer types are supported.. this means that we can duplicate artboards and symbols! By default `100px` offset is used for spacing between duplicated artboards, but it could be adjusted in the `Settings` dialog: 35 | 36 | ![Duplicating artboards and symbols](docs/duplicating-artboards-and-symbols.gif?raw=true) 37 | 38 | ### Manually adjusting offsets between duplicates 39 | 40 | Create a first duplicate of a selected layer(s) using `command-control + arrow` shortcut and adjust vertical or horizontal spacing between original layer(s) and duplicate, then repeat in the same direction until you have the desired duplicates. This feature works for any types of layers, including artboards and symbols: 41 | 42 | ![Manually adjusting offsets between duplicates](docs/remembering-offsets.gif?raw=true) 43 | 44 | ### Duplicating layers a given number of times with custom offset 45 | 46 | In case you want to create a certain number of copies in a specified direction with specific offset between copies, you could use `command-control-shift + any arrow key` shortcuts to provide the required values and pick specific injection mode: 47 | 48 | ![Repeating](docs/repeaters-custom-offsets.gif?raw=true) 49 | 50 | It's also possible to adjust offsets between duplicates in context by creating duplicate once using `command control + arrow` shortcut and adjusting vertical or horizontal spacing, then you could use `command-control-shift + the same arrow key` to create multiple copies at once. In such case offset will be picked up automatically: 51 | 52 | ![Manually adjusting offsets between duplicates](docs/repeaters-adjusted-offsets.gif?raw=true) 53 | 54 | 55 | ### Changing default settings 56 | 57 | Default settings could be changed in special dialog by selecting `Sketch Menu > Plugins > Duplication > Settings` menu item: 58 | 59 | ![Settings Dialog](docs/settings-dialog.png?raw=true) 60 | 61 | ## Version history 62 | 63 | **Duplicator 2.0.3: 2/8/2019** 64 | * Sketch 53 support 65 | * BUGFIX: Wrong layers ordering for `Before Selection` injection mode 66 | * BUGFIX: Added missing icon 67 | 68 | **Duplicator 2.0.2: 3/16/2018** 69 | * Sketch 49 support 70 | 71 | **Duplicator 2.0.1: 9/15/2017** 72 | * BUGFIX: Nested duplicated layers can't be selected on canvas 73 | 74 | **Duplicator 2.0.0: 8/3/2017** 75 | * Artboards and symbols support 76 | * Ability to manually adjust offsets between duplicates 77 | * Customizable injection mode 78 | * Sketch 46 support 79 | 80 | **Duplicator 1.1.0: 5/24/2016** 81 | * New bundle format support 82 | * Sketch 3.8+ support 83 | 84 | **Duplicator 1.0.0: 7/9/2014** 85 | * Initial Release 86 | 87 | ## Feedback 88 | 89 | If you discover any issue or have any suggestions for improvement of the plugin, please [open an issue](https://github.com/turbobabr/duplicator/issues) or find me on twitter [@turbobabr](http://twitter.com/turbobabr). 90 | 91 | ## License 92 | 93 | The MIT License (MIT) 94 | 95 | Copyright (c) 2014-2018 Andrey Shakhmin 96 | 97 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 98 | 99 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 100 | 101 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/prop-editor.js: -------------------------------------------------------------------------------- 1 | 2 | import _ from 'lodash'; 3 | import Utils from './utils'; 4 | 5 | export const PropEditorType = { 6 | TextField: 'textField', 7 | PopupButton: 'popupButton' 8 | }; 9 | 10 | export const PropType = { 11 | String: 'string', 12 | Number: 'number', 13 | List: 'list' 14 | }; 15 | 16 | // FIXME: This is a dirty.. dirty hack! 17 | const loadImageFromResources = (filePath) => { 18 | const command = coscript.printController(); 19 | const pluginBundle = command.pluginBundle(); 20 | const bundleUrl = pluginBundle.url(); 21 | 22 | const bundle = NSBundle.bundleWithURL(bundleUrl); 23 | 24 | const parts = filePath.split("."); 25 | const actualPath = bundle.pathForResource_ofType(parts[0],parts[1]); 26 | 27 | return NSImage.alloc().initWithContentsOfFile(actualPath); 28 | }; 29 | 30 | export default class PropEditorDialog { 31 | constructor(options = {}) { 32 | this._alert = NSAlert.alloc().init(); 33 | this._showDebugContentView = false; 34 | this.setup(options); 35 | } 36 | 37 | set title(value) { 38 | this._alert.messageText = value; 39 | } 40 | 41 | set icon(value) { 42 | if(_.isString(value)) { 43 | this._alert.icon = loadImageFromResources(value); 44 | return; 45 | } 46 | 47 | this._alert.icon = value; 48 | } 49 | 50 | set description(value) { 51 | this._alert.informativeText = value; 52 | } 53 | 54 | get window() { 55 | return this._alert.window(); 56 | 57 | } 58 | 59 | setup(options = {}) { 60 | options = _.assign({ 61 | title: 'Default Title', 62 | description: '', 63 | contentViewWidth: 320 64 | },options); 65 | 66 | this.options = options; 67 | this._views = []; 68 | this._responsesMap = {}; 69 | 70 | this.title = this.options.title; 71 | this.description = this.options.description; 72 | this.icon = this.options.icon; 73 | 74 | _.each(this.options.buttons,(button,index) => { 75 | this._alert.addButtonWithTitle(button.title); 76 | this._responsesMap[`${1000 + index}`] = button.id; 77 | }); 78 | 79 | _.eachRight(this.options.props,(prop) => { 80 | let control = null; 81 | switch(prop.editorType) { 82 | case PropEditorType.TextField: 83 | control = this.addTextField(prop); 84 | break; 85 | 86 | case PropEditorType.PopupButton: 87 | control = this.addPopupButton(prop); 88 | break; 89 | } 90 | 91 | prop.control = control; 92 | 93 | if(!_.isEmpty(prop.label)) { 94 | this.addLabel(prop.label); 95 | } 96 | }); 97 | } 98 | 99 | addLabel(text) { 100 | const textField = NSTextField.alloc().initWithFrame(NSMakeRect(0,0,this.options.contentViewWidth,16)); 101 | textField.setDrawsBackground(false); 102 | textField.setEditable(false); 103 | textField.setBezeled(false); 104 | textField.setSelectable(false); 105 | 106 | textField.setStringValue(text); 107 | this._views.push(textField); 108 | 109 | return textField; 110 | } 111 | 112 | addTextField(options) { 113 | const textField = NSTextField.alloc().initWithFrame(NSMakeRect(0,0,this.options.contentViewWidth,24)); 114 | textField.setStringValue(options.value); 115 | this._views.push(textField); 116 | 117 | return textField; 118 | } 119 | 120 | addPopupButton(options) { 121 | const button = NSPopUpButton.alloc().initWithFrame(NSMakeRect(0,0,this.options.contentViewWidth,24)); 122 | const menu = button.menu(); 123 | _.each(options.list,(item) => { 124 | const menuItem = NSMenuItem.alloc().init(); 125 | menuItem.title = item.label; 126 | menuItem.representedObject = item.name; 127 | 128 | menu.addItem(menuItem); 129 | }); 130 | 131 | const itemToSelect = _.find(menu.itemArray(),(item) => { 132 | if(Utils.normalize(item.representedObject()) == options.value) { 133 | return item; 134 | } 135 | }); 136 | 137 | if(itemToSelect) { 138 | button.selectItem(itemToSelect); 139 | } 140 | 141 | this._views.push(button); 142 | return button; 143 | } 144 | 145 | layout() { 146 | const view = NSView.alloc().initWithFrame(NSMakeRect(0, 0, this.options.contentViewWidth, 1)); 147 | 148 | if(this._showDebugContentView) { 149 | view.wantsLayer = true; 150 | view.layer().backgroundColor = NSColor.redColor().CGColor(); 151 | } 152 | 153 | let height = 0; 154 | _.each(this._views,(subView) => { 155 | let currentFrame = subView.bounds(); 156 | currentFrame.origin.y = height; 157 | height += currentFrame.size.height + 8; 158 | subView.setFrame(currentFrame); 159 | 160 | view.addSubview(subView); 161 | }); 162 | 163 | let viewFrame = view.frame(); 164 | viewFrame.size.height = height; 165 | 166 | view.setFrame(viewFrame); 167 | this._alert.setAccessoryView(view); 168 | 169 | const firstEditableView = _.find(_.reverse(this._views),(view) => { 170 | return view.isKindOfClass(NSTextField) && view.isEditable() == true; 171 | }); 172 | 173 | if(firstEditableView) { 174 | this.window.setInitialFirstResponder(firstEditableView); 175 | } 176 | 177 | const respondersChain = this.findResponders(); 178 | _.each(respondersChain,(responder,index) => { 179 | if(index + 1 < respondersChain.length) { 180 | responder.setNextKeyView(respondersChain[index+1]); 181 | } else if(respondersChain.length > 1) { 182 | responder.setNextKeyView(respondersChain[0]); 183 | } 184 | }); 185 | } 186 | 187 | findResponders() { 188 | return _.filter(this._views,(view) => { 189 | return (view.isKindOfClass(NSTextField) && view.isEditable()) || view.isKindOfClass(NSPopUpButton); 190 | }); 191 | } 192 | 193 | collectProps() { 194 | let props = {}; 195 | _.each(this.options.props,(prop) => { 196 | if(!prop.control) { 197 | return; 198 | } 199 | 200 | if(prop.type == PropType.Number && prop.control.isKindOfClass(NSTextField)) { 201 | props[prop.name] = parseFloat(Utils.normalize(prop.control.stringValue())); 202 | } else if(prop.type == PropType.List && prop.control.isKindOfClass(NSPopUpButton)) { 203 | props[prop.name] = Utils.normalize(prop.control.selectedItem().representedObject()); 204 | } 205 | }); 206 | 207 | return props; 208 | } 209 | 210 | show(callback = function() {}) { 211 | this.layout(); 212 | 213 | callback(`${this._responsesMap[`${this._alert.runModal()}`]}`,this.collectProps()); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/duplicate.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import Storage from './thread-storage'; 4 | import { 5 | PREVIOUS_STENCIL_DESCRIPTOR_KEY, 6 | PRESERVED_OFFSETS_KEY, 7 | Direction, 8 | InjectionMode 9 | } from './constants'; 10 | 11 | import { defaultSettings } from './settings'; 12 | import PropEditorDialog, { PropType, PropEditorType } from './prop-editor'; 13 | 14 | const arrayFastEach = (array,iteratee) => { 15 | iteratee = iteratee || function() {}; 16 | 17 | if(toString.call(array) === '[object Array]') { 18 | for(let i=0;i { 50 | let result = NSMutableDictionary.new(); 51 | 52 | const distinctParentIDs = layers.valueForKeyPath("@distinctUnionOfObjects.parentGroup.objectID"); 53 | for (let i = 0 ;i< distinctParentIDs.count();i++) { 54 | const objectID = distinctParentIDs.objectAtIndex(i); 55 | 56 | const predicate = NSPredicate.predicateWithFormat("parentGroup.objectID = %@", objectID); 57 | const layersCluster = layers.filteredArrayUsingPredicate(predicate); 58 | result.setObject_forKey(layersCluster,objectID); 59 | } 60 | 61 | return result; 62 | }; 63 | 64 | const makeClustersDescriptor = (clusters,direction) => { 65 | 66 | let hash = { 67 | direction, 68 | snapshot: {}, 69 | bounds: {}, 70 | identities: {} 71 | }; 72 | 73 | let keys = clusters.allKeys(); 74 | arrayFastEach(keys,(key) => { 75 | let cluster = clusters[key]; 76 | let bounds = groupBoundsForLayers(cluster); 77 | 78 | let firstLayer = cluster.firstObject(); 79 | 80 | hash.snapshot[key]=`${bounds.size.width}x${bounds.size.height}x${cluster.count()}-${firstLayer ? firstLayer.objectID() : 'empty'}`; 81 | hash.bounds[key] = { 82 | x: bounds.origin.x, 83 | y: bounds.origin.y, 84 | width: bounds.size.width, 85 | height: bounds.size.height 86 | }; 87 | }); 88 | 89 | return NSDictionary.dictionaryWithDictionary(hash); 90 | }; 91 | 92 | const compareClustersDescriptors = (clustersA,clustersB) => { 93 | if(clustersA.direction != clustersB.direction) { 94 | return false; 95 | } 96 | 97 | let keysA = clustersA.snapshot.allKeys(); 98 | let keysB = clustersB.snapshot.allKeys(); 99 | 100 | if(keysA.count() != keysB.count()) { 101 | return false; 102 | } 103 | 104 | for(var i=0;i { 141 | if(!layers || layers.count()<1) { 142 | return; 143 | } 144 | 145 | options = defaultSettings(options); 146 | 147 | const { direction, defaultOffset, defaultArtboardOffset, injectionMode } = options; 148 | let clusters = groupLayersByParentGroup(layers); 149 | 150 | let currentStencilDescriptor = makeClustersDescriptor(clusters,direction); 151 | 152 | let prevStencilDescriptor = null; 153 | if(Storage.exists(PREVIOUS_STENCIL_DESCRIPTOR_KEY)) { 154 | prevStencilDescriptor = Storage.get(PREVIOUS_STENCIL_DESCRIPTOR_KEY); 155 | } 156 | 157 | let keys = clusters.allKeys(); 158 | let allDuplicates = []; 159 | 160 | let offsetDeltasForClusters = {}; 161 | let prevOffsetDeltasForClusters = Storage.get(PRESERVED_OFFSETS_KEY); 162 | 163 | arrayFastEach(keys,(key) => { 164 | let cluster = clusters[key]; 165 | let originalBounds = groupBoundsForLayers(cluster); 166 | 167 | let offsetDelta = 0; 168 | if(prevStencilDescriptor) { 169 | if(compareClustersDescriptors(currentStencilDescriptor,prevStencilDescriptor)) { 170 | offsetDelta = originalBounds.origin.y-prevStencilDescriptor.bounds[key].y; 171 | 172 | switch(direction) { 173 | case Direction.Below: 174 | offsetDelta = originalBounds.origin.y-prevStencilDescriptor.bounds[key].y; 175 | break; 176 | 177 | case Direction.Right: 178 | offsetDelta = originalBounds.origin.x-prevStencilDescriptor.bounds[key].x; 179 | break; 180 | 181 | case Direction.Left: 182 | offsetDelta = (prevStencilDescriptor.bounds[key].x+prevStencilDescriptor.bounds[key].width) - (originalBounds.origin.x+originalBounds.size.width); 183 | break; 184 | 185 | case Direction.Above: 186 | offsetDelta = (prevStencilDescriptor.bounds[key].y+prevStencilDescriptor.bounds[key].height) - (originalBounds.origin.y+originalBounds.size.height); 187 | break; 188 | } 189 | 190 | if(prevOffsetDeltasForClusters && !_.isUndefined(prevOffsetDeltasForClusters[key]) && prevOffsetDeltasForClusters[key]) { 191 | if(offsetDelta == 0) { 192 | offsetDelta = prevOffsetDeltasForClusters[key]; 193 | } else { 194 | offsetDelta = prevOffsetDeltasForClusters[key] + offsetDelta; 195 | } 196 | } 197 | 198 | offsetDeltasForClusters[key] = offsetDelta; 199 | } else { 200 | Storage.remove(PREVIOUS_STENCIL_DESCRIPTOR_KEY); 201 | } 202 | } 203 | 204 | 205 | let duplicates = []; 206 | arrayFastEach(cluster,(layer) => { 207 | duplicates.push(injectionMode === InjectionMode.Default ? layer.duplicate() : layer.copy()); 208 | }); 209 | 210 | if(injectionMode !== InjectionMode.Default) { 211 | let parentGroup = cluster.firstObject().parentGroup(); 212 | if(!parentGroup) { 213 | return; 214 | } 215 | 216 | switch(injectionMode) { 217 | case InjectionMode.AfterSelection: 218 | parentGroup.insertLayers_afterLayer(duplicates,cluster.lastObject()); 219 | break; 220 | case InjectionMode.BeforeSelection: 221 | parentGroup.insertLayers_beforeLayer(NSArray.arrayWithArray(duplicates).reverseObjectEnumerator().allObjects(),cluster.firstObject()); 222 | break; 223 | } 224 | } 225 | 226 | let newBounds = groupBoundsForLayers(duplicates); 227 | arrayFastEach(duplicates,(layer) => { 228 | 229 | const offset = (layer.isKindOfClass(MSArtboardGroup) ? defaultArtboardOffset : defaultOffset) + (!options.ignoreOffsetDelta ? offsetDelta : 0); 230 | 231 | switch(direction) { 232 | case Direction.Left: 233 | { 234 | layer.frame().x = originalBounds.origin.x-originalBounds.size.width + (layer.frame().x()-newBounds.origin.x)-offset; 235 | layer.frame().y = originalBounds.origin.y + (layer.frame().y()-newBounds.origin.y); 236 | } break; 237 | 238 | case Direction.Above: 239 | { 240 | layer.frame().x = originalBounds.origin.x + (layer.frame().x()-newBounds.origin.x); 241 | layer.frame().y = originalBounds.origin.y - originalBounds.size.height + (layer.frame().y()-newBounds.origin.y)-offset; 242 | 243 | } break; 244 | 245 | case Direction.Below: 246 | { 247 | layer.frame().x = originalBounds.origin.x + (layer.frame().x()-newBounds.origin.x); 248 | layer.frame().y = originalBounds.origin.y + originalBounds.size.height + (layer.frame().y()-newBounds.origin.y)+offset; 249 | 250 | } break; 251 | 252 | case Direction.Right: 253 | { 254 | layer.frame().x = originalBounds.origin.x + originalBounds.size.width + (layer.frame().x()-newBounds.origin.x)+offset; 255 | layer.frame().y = originalBounds.origin.y + (layer.frame().y()-newBounds.origin.y); 256 | } break; 257 | } 258 | }); 259 | 260 | allDuplicates = allDuplicates.concat(duplicates); 261 | }); 262 | 263 | 264 | // Adjust frames of parents of the duplicated layers. 265 | let affectedParents = layers.valueForKeyPath('@distinctUnionOfObjects.parentGroup'); 266 | arrayFastEach(affectedParents,(layer) => { 267 | // FIXME: It's gone! 🤔 268 | layer.fixGeometryWithOptions(1); 269 | }); 270 | 271 | arrayFastEach(layers,(layer) => { 272 | layer.select_byExtendingSelection(false,false); 273 | }); 274 | 275 | arrayFastEach(allDuplicates,(layer) => { 276 | layer.select_byExtendingSelection(true,true); 277 | }); 278 | 279 | let stencilDescriptor = makeClustersDescriptor(groupLayersByParentGroup(NSArray.arrayWithArray(allDuplicates)),direction); 280 | Storage.set(PREVIOUS_STENCIL_DESCRIPTOR_KEY,stencilDescriptor); 281 | Storage.set(PRESERVED_OFFSETS_KEY,NSDictionary.dictionaryWithDictionary(offsetDeltasForClusters)); 282 | 283 | return allDuplicates; 284 | }; 285 | 286 | export const duplicateOnce = (layers,direction) => { 287 | duplicate(layers,{ direction: direction }); 288 | }; 289 | 290 | 291 | const buildRepeaterDialogConfiguration = (direction) => { 292 | 293 | switch(direction) { 294 | case Direction.Left: 295 | return { 296 | title: 'Repeat Left', 297 | description: 'This tool takes the current selection and copies it a specified number of times to the left', 298 | icon: 'ic-duplicate-left.png', 299 | }; 300 | break; 301 | 302 | case Direction.Right: 303 | return { 304 | title: 'Repeat Right', 305 | description: 'This tool takes the current selection and copies it a specified number of times to the right', 306 | icon: 'ic-duplicate-right.png', 307 | }; 308 | break; 309 | 310 | case Direction.Above: 311 | return { 312 | title: 'Repeat Above', 313 | description: 'This tool takes the current selection and copies it a specified number of times above the selection', 314 | icon: 'ic-duplicate-above.png', 315 | }; 316 | break; 317 | 318 | case Direction.Below: 319 | return { 320 | title: 'Repeat Below', 321 | description: 'This tool takes the current selection and copies it a specified number of times below the selection', 322 | icon: 'ic-duplicate-below.png', 323 | }; 324 | break; 325 | 326 | } 327 | 328 | return {}; 329 | }; 330 | 331 | const findSelectedLayers = (document) => { 332 | document = document || MSDocument.currentDocument(); 333 | return document.selectedLayers().layers(); 334 | }; 335 | 336 | const Containment = { 337 | Empty: 'empty', 338 | ArtboardsOnly: 'artboardsOnly', 339 | CommonLayers: 'layers', 340 | Mixed: 'mixed' 341 | }; 342 | 343 | const testContainment = (layers) => { 344 | if(!layers || layers.count() <1 ) { 345 | return Containment.Empty; 346 | } 347 | 348 | const artboards = layers.filteredArrayUsingPredicate(NSPredicate.predicateWithFormat("className == 'MSArtboardGroup' || className == 'MSSymbolMaster'")); 349 | if(layers.count() == artboards.count()) { 350 | return Containment.ArtboardsOnly; 351 | } 352 | 353 | if(artboards.count() > 0 && layers.count() != artboards.count()) { 354 | return Containment.Mixed; 355 | } 356 | 357 | return Containment.CommonLayers; 358 | }; 359 | 360 | const gatherClustersInfo = (layers,options) => { 361 | if(!layers || layers.count()<1) { 362 | return; 363 | } 364 | 365 | options = defaultSettings(options); 366 | 367 | const { direction } = options; 368 | let clusters = groupLayersByParentGroup(layers); 369 | 370 | let currentStencilDescriptor = makeClustersDescriptor(clusters,direction); 371 | 372 | let prevStencilDescriptor = null; 373 | if(Storage.exists(PREVIOUS_STENCIL_DESCRIPTOR_KEY)) { 374 | prevStencilDescriptor = Storage.get(PREVIOUS_STENCIL_DESCRIPTOR_KEY); 375 | } 376 | 377 | let keys = clusters.allKeys(); 378 | 379 | let offsetDeltasForClusters = {}; 380 | let prevOffsetDeltasForClusters = Storage.get(PRESERVED_OFFSETS_KEY); 381 | 382 | let offsetDelta = 0; 383 | arrayFastEach(keys,(key) => { 384 | let cluster = clusters[key]; 385 | let originalBounds = groupBoundsForLayers(cluster); 386 | 387 | 388 | if(prevStencilDescriptor) { 389 | if (compareClustersDescriptors(currentStencilDescriptor, prevStencilDescriptor)) { 390 | offsetDelta = originalBounds.origin.y - prevStencilDescriptor.bounds[key].y; 391 | 392 | switch (direction) { 393 | case Direction.Below: 394 | offsetDelta = originalBounds.origin.y - prevStencilDescriptor.bounds[key].y; 395 | break; 396 | 397 | case Direction.Right: 398 | offsetDelta = originalBounds.origin.x - prevStencilDescriptor.bounds[key].x; 399 | break; 400 | 401 | case Direction.Left: 402 | offsetDelta = (prevStencilDescriptor.bounds[key].x + prevStencilDescriptor.bounds[key].width) - (originalBounds.origin.x + originalBounds.size.width); 403 | break; 404 | 405 | case Direction.Above: 406 | offsetDelta = (prevStencilDescriptor.bounds[key].y + prevStencilDescriptor.bounds[key].height) - (originalBounds.origin.y + originalBounds.size.height); 407 | break; 408 | } 409 | 410 | if (prevOffsetDeltasForClusters && !_.isUndefined(prevOffsetDeltasForClusters[key]) && prevOffsetDeltasForClusters[key]) { 411 | if (offsetDelta == 0) { 412 | offsetDelta = prevOffsetDeltasForClusters[key]; 413 | } else { 414 | offsetDelta = prevOffsetDeltasForClusters[key] + offsetDelta; 415 | } 416 | } 417 | 418 | offsetDeltasForClusters[key] = offsetDelta; 419 | } 420 | } 421 | }); 422 | 423 | return { 424 | clusters, 425 | offsetDeltasForClusters 426 | }; 427 | }; 428 | 429 | export const duplicateWithRepeater = (layers,direction) => { 430 | const options = defaultSettings({}); 431 | let offset = 0; 432 | 433 | switch(testContainment(layers)) { 434 | case Containment.Empty: 435 | MSDocument.currentDocument().showMessage('[Duplicator]: Selection is empty!'); 436 | return; 437 | break; 438 | 439 | case Containment.ArtboardsOnly: 440 | offset = options.defaultArtboardOffset; 441 | break; 442 | 443 | case Containment.Mixed: 444 | case Containment.CommonLayers: 445 | offset = options.defaultOffset; 446 | break; 447 | } 448 | 449 | const clustersInfo = gatherClustersInfo(layers, { direction }); 450 | if(clustersInfo && _.keys(clustersInfo.offsetDeltasForClusters).length > 0) { 451 | let referenceOffset = _.get(clustersInfo.offsetDeltasForClusters,_.first(_.keys(clustersInfo.offsetDeltasForClusters))); 452 | if(_.every(_.keys(clustersInfo.offsetDeltasForClusters),(key) => { 453 | return _.get(clustersInfo.offsetDeltasForClusters,key) == referenceOffset; 454 | })) { 455 | offset += referenceOffset; 456 | } 457 | } 458 | 459 | const editor = new PropEditorDialog(_.assign( 460 | buildRepeaterDialogConfiguration(direction),{ 461 | props: [ 462 | { 463 | name: 'count', 464 | type: PropType.Number, 465 | editorType: PropEditorType.TextField, 466 | label: 'Count:', 467 | value: 1 468 | }, 469 | { 470 | name: 'offset', 471 | type: PropType.Number, 472 | editorType: PropEditorType.TextField, 473 | label: 'Spacing (pixels):', 474 | value: offset 475 | }, 476 | { 477 | name: 'injectionMode', 478 | type: PropType.List, 479 | list: [ 480 | { 481 | name: InjectionMode.Default, 482 | label: 'In Place' 483 | }, 484 | { 485 | name: InjectionMode.BeforeSelection, 486 | label: 'Before Selection' 487 | }, 488 | { 489 | name: InjectionMode.AfterSelection, 490 | label: 'After Selection' 491 | } 492 | ], 493 | editorType: PropEditorType.PopupButton, 494 | label: 'Injection Mode:', 495 | value: options.injectionMode 496 | } 497 | ], 498 | buttons: [ 499 | { 500 | title: "Duplicate", 501 | id: "ok" 502 | }, 503 | { 504 | title: "Cancel", 505 | id: "cancel" 506 | } 507 | ] 508 | })); 509 | 510 | editor.show((response,props) => { 511 | if(response != 'ok') { 512 | return; 513 | } 514 | 515 | let selection = []; 516 | _.times(props.count,() => { 517 | selection = selection.concat(duplicate(findSelectedLayers(),{ 518 | direction: direction, 519 | defaultOffset: props.offset, 520 | defaultArtboardOffset: props.offset, 521 | injectionMode: props.injectionMode, 522 | ignoreOffsetDelta: true 523 | })); 524 | }); 525 | 526 | arrayFastEach(selection,(layer) => { 527 | layer.select_byExtendingSelection(true,true); 528 | }); 529 | }); 530 | }; --------------------------------------------------------------------------------