├── .appcast.xml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── assets ├── FNR.ttf ├── appIcon.png ├── appIconDark.png ├── artboardIcon.svg ├── icon.png ├── layersIcon.svg ├── pageIcon.png ├── pageIcon.svg └── screenshot.png ├── package-lock.json ├── package.json ├── resources ├── styles.dark.css ├── styles.default.css ├── styles.light.css ├── webview.html └── webview.js ├── src ├── elements.js ├── events.js ├── find-and-replace-text-command.js ├── manifest.json ├── scanner.js └── turnstile.js └── webpack.skpm.config.js /.appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artifacts 2 | find-and-replace-text.sketchplugin 3 | 4 | # npm 5 | node_modules 6 | .npm 7 | npm-debug.log 8 | 9 | # mac 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v1.3.0](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.3.0) (2019-06-20) 4 | [Full Changelog](https://github.com/chriswetterman/sketch-find-and-replace-text/compare/v1.2.1...v1.3.0) 5 | 6 | **Implemented enhancements:** 7 | 8 | - Provide option for renaming layers [\#11](https://github.com/chriswetterman/sketch-find-and-replace-text/issues/11) 9 | 10 | **Fixed bugs:** 11 | 12 | - Renaming only affects the first occurrence in the field [\#12](https://github.com/chriswetterman/sketch-find-and-replace-text/issues/12) 13 | 14 | **Closed issues:** 15 | 16 | - Truncating Layer Names [\#10](https://github.com/chriswetterman/sketch-find-and-replace-text/issues/10) 17 | 18 | **Merged pull requests:** 19 | 20 | - Layer rename option [\#13](https://github.com/chriswetterman/sketch-find-and-replace-text/pull/13) ([chriswetterman](https://github.com/chriswetterman)) 21 | 22 | ## [v1.2.1](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.2.1) (2019-05-06) 23 | [Full Changelog](https://github.com/chriswetterman/sketch-find-and-replace-text/compare/v1.1.1...v1.2.1) 24 | 25 | **Implemented enhancements:** 26 | 27 | - Add Case-Sensitive and Whole Word Search Options [\#5](https://github.com/chriswetterman/sketch-find-and-replace-text/issues/5) 28 | - Case sensitive whole word search [\#6](https://github.com/chriswetterman/sketch-find-and-replace-text/pull/6) ([chriswetterman](https://github.com/chriswetterman)) 29 | 30 | **Fixed bugs:** 31 | 32 | - "Find Next" doesn't seem to center the selection or target the correct artboard [\#4](https://github.com/chriswetterman/sketch-find-and-replace-text/issues/4) 33 | 34 | **Merged pull requests:** 35 | 36 | - Search options ui tweak [\#9](https://github.com/chriswetterman/sketch-find-and-replace-text/pull/9) ([chriswetterman](https://github.com/chriswetterman)) 37 | - Issue 4 [\#7](https://github.com/chriswetterman/sketch-find-and-replace-text/pull/7) ([chriswetterman](https://github.com/chriswetterman)) 38 | 39 | ## [v1.1.1](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.1.1) (2019-04-16) 40 | [Full Changelog](https://github.com/chriswetterman/sketch-find-and-replace-text/compare/v1.1.0...v1.1.1) 41 | 42 | **Fixed bugs:** 43 | 44 | - After this update, plugin didn't work [\#2](https://github.com/chriswetterman/sketch-find-and-replace-text/issues/2) 45 | 46 | **Merged pull requests:** 47 | 48 | - Fixing execution of theme setting [\#3](https://github.com/chriswetterman/sketch-find-and-replace-text/pull/3) ([chriswetterman](https://github.com/chriswetterman)) 49 | 50 | ## [v1.1.0](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.1.0) (2019-04-14) 51 | [Full Changelog](https://github.com/chriswetterman/sketch-find-and-replace-text/compare/v1.0.2...v1.1.0) 52 | 53 | **Merged pull requests:** 54 | 55 | - Support dark mode [\#1](https://github.com/chriswetterman/sketch-find-and-replace-text/pull/1) ([chriswetterman](https://github.com/chriswetterman)) 56 | 57 | ## [v1.0.2](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.0.2) (2019-04-08) 58 | [Full Changelog](https://github.com/chriswetterman/sketch-find-and-replace-text/compare/v1.0.1...v1.0.2) 59 | 60 | ## [v1.0.1](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.0.1) (2019-04-08) 61 | [Full Changelog](https://github.com/chriswetterman/sketch-find-and-replace-text/compare/v1.0.0...v1.0.1) 62 | 63 | ## [v1.0.0](https://github.com/chriswetterman/sketch-find-and-replace-text/tree/v1.0.0) (2019-04-05) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 🕵🏻‍ Find and Replace Text plugin for Sketch 3 | 4 | 5 | 6 | Find and Replace Text for Sketch allows you to search throughout your active document and replace text within canvas elements (symbol overrides included). You can also choose to perform a rename on just the layer names as well. Searches can be scoped at the following levels based on your current layer list selection: Document, Page, Artboard, Layer 7 | 8 | ## Installation 9 | 10 | Download the [latest release](https://github.com/chriswetterman/sketch-find-and-replace-text/releases/latest/download/find-and-replace-text.sketchplugin.zip), unzip then double-click `find-and-replace-text.sketchplugin` to install. 11 | 12 | ## Usage 13 | 14 | Launch Find and Replace Text from the Plugins menu or use the keyboard shortcut, **CMD+SHIFT+F**. 15 | 16 | ## Support 17 | 18 | Find and Replace Text supports Sketch 53+. Please open an issue for any problems or feature requests! 19 | 20 | -------------------------------------------------------------------------------- /assets/FNR.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/FNR.ttf -------------------------------------------------------------------------------- /assets/appIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/appIcon.png -------------------------------------------------------------------------------- /assets/appIconDark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/appIconDark.png -------------------------------------------------------------------------------- /assets/artboardIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ArtboardIcon 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/icon.png -------------------------------------------------------------------------------- /assets/layersIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Layers 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /assets/pageIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/pageIcon.png -------------------------------------------------------------------------------- /assets/pageIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | PageIcon 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find-and-replace-text", 3 | "version": "1.3.1", 4 | "engines": { 5 | "sketch": ">=3.0" 6 | }, 7 | "skpm": { 8 | "name": "Find and Replace Text", 9 | "manifest": "src/manifest.json", 10 | "main": "find-and-replace-text.sketchplugin", 11 | "assets": [ 12 | "assets/**/*.otf" 13 | ] 14 | }, 15 | "scripts": { 16 | "build": "skpm-build", 17 | "watch": "skpm-build --watch", 18 | "start": "skpm-build --watch", 19 | "postinstall": "npm run build && skpm-link" 20 | }, 21 | "devDependencies": { 22 | "@skpm/builder": "^0.5.16", 23 | "@skpm/extract-loader": "^2.0.2", 24 | "copy-webpack-plugin": "^5.0.2", 25 | "css-loader": "^1.0.0", 26 | "html-loader": "^0.5.1" 27 | }, 28 | "resources": [ 29 | "resources/**/*.js" 30 | ], 31 | "dependencies": { 32 | "sketch-module-web-view": "^3.2.1" 33 | }, 34 | "author": "Chris Wetterman ", 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/chriswetterman/sketch-find-and-replace-text.git" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /resources/styles.dark.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #DDD; 3 | } 4 | 5 | p { 6 | color: #999; 7 | } 8 | 9 | input { 10 | background-color: #4d4c50; 11 | color: #DDD; 12 | } 13 | 14 | .header { 15 | background-image: linear-gradient(-180deg, #201F1F 0%, #242324 100%); 16 | border-bottom: 1px solid #3a3939; 17 | } 18 | 19 | .content { 20 | background-image: linear-gradient(-180deg, #2C2D2C 0%, #2D2C2D 100%); 21 | } 22 | 23 | .btn-plain { 24 | background-image: linear-gradient(-180deg, #646464 0%, #616161 100%); 25 | border-top: 1px solid #2B2A2A; 26 | border-left: 1px solid #2A2929; 27 | border-right: 1px solid #2A2929; 28 | border-bottom: 1px solid #252525; 29 | color: #e7e7e7; 30 | } 31 | 32 | 33 | .btn-iconified { 34 | background-image: linear-gradient(-180deg, #646464 0%, #616161 100%); 35 | border: 1px solid #252525; 36 | color: #e7e7e7; 37 | } 38 | 39 | .disabled { 40 | border-top: 1px solid #2C2B2B; 41 | border-left: 1px solid #2C2B2B; 42 | border-right: 1px solid #2C2B2B; 43 | border-bottom: 1px solid #2A2929; 44 | color: #727272; 45 | background-color: #474646; 46 | background-image: none; 47 | } 48 | 49 | .disabled > img { 50 | opacity: 0.4; 51 | } 52 | 53 | .btn-iconified.active { 54 | border: 1px #FB9F27 solid; 55 | background: rgba(255, 201, 67, 0.98); 56 | color: #222; 57 | } 58 | 59 | .icon-caseSensitive, .icon-wholeWord, .icon-layers { 60 | border: 1px solid #4d4c50; 61 | color: #999; 62 | } 63 | 64 | .icon-caseSensitive.active, .icon-wholeWord.active, .icon-layers.active { 65 | border: 1px solid #ffc943; 66 | color: #DDD; 67 | } 68 | -------------------------------------------------------------------------------- /resources/styles.default.css: -------------------------------------------------------------------------------- 1 | /* some default styles to make the view more native like */ 2 | 3 | @font-face { 4 | font-family: 'FNR'; 5 | src: url('../assets/FNR.ttf') format('truetype'); 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | html { 11 | box-sizing: border-box; 12 | background: transparent; 13 | 14 | /* Prevent the page to be scrollable */ 15 | overflow: hidden; 16 | 17 | /* Force the default cursor, even on text */ 18 | cursor: default; 19 | } 20 | 21 | *, *:before, *:after { 22 | box-sizing: border-box; 23 | margin: 0; 24 | padding: 0; 25 | position: relative; 26 | 27 | /* Prevent the content from being selectionable */ 28 | -webkit-user-select: none; 29 | user-select: none; 30 | } 31 | 32 | body { 33 | font-family: 'Helvetica Neue', system-ui; 34 | font-size: 14px; 35 | } 36 | 37 | p { 38 | line-height: 1.35em; 39 | } 40 | 41 | input { 42 | width: 80%; 43 | font-size: 0.875rem; 44 | margin-left: 0.5rem; 45 | padding-left: 4px; 46 | padding-right: 4px; 47 | } 48 | 49 | input.find { 50 | padding-right: 5.5em; 51 | } 52 | 53 | input, textarea { 54 | -webkit-user-select: auto; 55 | user-select: auto; 56 | } 57 | 58 | input:focus { 59 | outline: #3277E3; 60 | } 61 | 62 | a:active { 63 | background-clip: #3277E3; 64 | } 65 | 66 | img { 67 | vertical-align: top; 68 | } 69 | 70 | 71 | 72 | .icon-caseSensitive { 73 | font-family: 'FNR'; 74 | display: inline-block; 75 | margin-left: -6.25em; 76 | margin-right: -0.1em; 77 | font-size: 0.875em; 78 | padding: 1px; 79 | border: 1px solid #000; 80 | cursor: pointer; 81 | top: -1px; 82 | } 83 | 84 | .icon-wholeWord { 85 | font-family: 'FNR'; 86 | display: inline-block; 87 | font-size: 0.875em; 88 | cursor: pointer; 89 | padding: 1px; 90 | border: 1px solid #000; 91 | top: -1px; 92 | } 93 | 94 | .icon-layers { 95 | font-family: 'FNR'; 96 | display: inline-block; 97 | font-size: 0.875em; 98 | cursor: pointer; 99 | padding: 1px; 100 | border: 1px solid #000; 101 | top: -1px; 102 | } 103 | 104 | .icon-wholeWord:before { 105 | content: "\e900"; 106 | } 107 | 108 | .icon-caseSensitive:before { 109 | content: "\e901"; 110 | } 111 | 112 | .icon-layers:before { 113 | content: "\e902"; 114 | } 115 | 116 | .header { 117 | padding: 12px 20px; 118 | } 119 | 120 | .header > img { 121 | margin-top: 4px; 122 | } 123 | 124 | .title { 125 | display: inline-block; 126 | margin-left: 0.6875rem; 127 | width: 330px; 128 | font-size: 0.875em; 129 | } 130 | 131 | .title span { 132 | font-weight: 700; 133 | line-height: 1.6em; 134 | } 135 | 136 | .content { 137 | padding: 16px 20px; 138 | } 139 | 140 | .container { 141 | width: 100%; 142 | padding-right: 15px; 143 | padding-left: 15px; 144 | margin-right: auto; 145 | margin-left: auto; 146 | } 147 | 148 | .row { 149 | display: flex; 150 | flex-wrap: wrap; 151 | margin-right: -15px; 152 | margin-left: -15px; 153 | margin-bottom: 0.5rem; 154 | } 155 | 156 | .row-x2 { 157 | display: flex; 158 | flex-wrap: wrap; 159 | margin-right: -15px; 160 | margin-left: -15px; 161 | margin-bottom: 1rem; 162 | } 163 | 164 | .col { 165 | flex-basis: 0; 166 | flex-grow: 1; 167 | max-width: 100%; 168 | } 169 | 170 | .col-1 { 171 | flex: 0 0 25%; 172 | max-width: 25%; 173 | } 174 | 175 | .field-label { 176 | top: 2px; 177 | text-align: right; 178 | font-size: 0.9em; 179 | } 180 | 181 | .align-left { 182 | text-align: left; 183 | } 184 | 185 | .btn-plain { 186 | display: block; 187 | border-radius: 4px; 188 | width: 81px; 189 | text-align: center; 190 | height: 24px; 191 | text-decoration: none; 192 | padding-top: 4px; 193 | font-size: 13px; 194 | } 195 | 196 | btn-plain.disabled { 197 | cursor: default; 198 | } 199 | 200 | .btn-iconified { 201 | display: inline-block; 202 | border-radius: 6px; 203 | width: 57px; 204 | height: 60px; 205 | font-size: 11px; 206 | text-align: center; 207 | text-decoration: none; 208 | padding-top: 2px; 209 | margin-right: 4px; 210 | } 211 | 212 | .disabled { 213 | cursor: default; 214 | } 215 | 216 | .disabled > img { 217 | opacity: 0.25; 218 | } 219 | 220 | .btn-iconified > img { 221 | display: block; 222 | margin: 6px auto; 223 | } 224 | 225 | .mrg-b { 226 | margin-bottom: 6px; 227 | } 228 | 229 | .mrg-t { 230 | margin-top: 27px; 231 | } 232 | 233 | .col-3 { 234 | position: relative; 235 | width: 100%; 236 | padding-right: 15px; 237 | padding-left: 3px; 238 | flex: 0 0 25%; 239 | max-width: 25%; 240 | } 241 | 242 | .col-9 { 243 | position: relative; 244 | width: 100%; 245 | padding-right: 20px; 246 | padding-left: 20px; 247 | flex: 0 0 75%; 248 | max-width: 75%; 249 | } 250 | -------------------------------------------------------------------------------- /resources/styles.light.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #444; 3 | } 4 | 5 | p { 6 | color: #7C7C7C; 7 | } 8 | 9 | .header { 10 | background-image: linear-gradient(-180deg, #F6F6F6 0%, #F5F6F6 100%); 11 | border-bottom: 1px solid #DFDEDE; 12 | } 13 | 14 | .content { 15 | background-image: linear-gradient(-180deg, #F0F0F0 0%, #EEF0EF 100%); 16 | } 17 | 18 | .btn-plain { 19 | /* background-image: linear-gradient(-180deg, #E2E2E2 0%, #DCDCDC 99%); */ 20 | background-color: #FFF; 21 | border-top: 1px solid #D9D9D9; 22 | border-left: 1px solid #D6D5D5; 23 | border-right: 1px solid #D6D5D5; 24 | border-bottom: 1px solid #C2C1C1; 25 | color: #262626; 26 | } 27 | 28 | .btn-iconified { 29 | background: #F4F4F4; 30 | border: 1px solid #979797; 31 | color: #222; 32 | } 33 | 34 | .disabled { 35 | border-top: 1px solid #E4E3E3; 36 | border-left: 1px solid #E2E1E1; 37 | border-right: 1px solid #E2E1E1; 38 | border-bottom: 1px solid #D9D8D8; 39 | background-color: #F7F7F7; 40 | color: #BDBDBD; 41 | } 42 | 43 | .disabled > img { 44 | opacity: 0.25; 45 | } 46 | 47 | .btn-iconified.active { 48 | border: 1px #FB9F27 solid; 49 | background: rgba(255, 201, 67, 0.98); 50 | } 51 | 52 | .icon-caseSensitive, .icon-wholeWord, .icon-layers { 53 | border: 1px solid #FFF; 54 | color: #C2C1C1; 55 | } 56 | 57 | .icon-caseSensitive.active, .icon-wholeWord.active, .icon-layers.active { 58 | border: 1px solid #FB9F27; 59 | color: #262626; 60 | } 61 | -------------------------------------------------------------------------------- /resources/webview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Find and Replace Text 6 | 7 | 8 | 9 |
10 | 11 |
12 | Find and Replace Text 13 |

Replace text throughout your selected layers, selected artboards, pages or document.

14 |
15 |
16 |
17 |
18 |
Find: 19 |
20 |
21 | 22 | 23 | 24 | 25 |
26 |
27 |
28 |
29 | Replace with: 30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 |
Scope:
38 |
39 | 40 |
41 |
42 | Document Page Artboard Layer 43 |
44 |
45 | 48 |
49 |
50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /resources/webview.js: -------------------------------------------------------------------------------- 1 | import * as Events from '../src/events'; 2 | 3 | // Disable the context menu to have a more native feel 4 | document.addEventListener("contextmenu", function(e) { 5 | e.preventDefault(); 6 | }); 7 | 8 | document.getElementById('scope_layer').addEventListener('click', function() { ifEnabled(this, () => window.postMessage(Events.kEventScopeChange, Events.kScopeChangeTypeLayer)) }) 9 | document.getElementById('scope_artboard').addEventListener('click', function() { ifEnabled(this, () => window.postMessage(Events.kEventScopeChange, Events.kScopeChangeTypeArtboard)) }) 10 | document.getElementById('scope_page').addEventListener('click', function() { ifEnabled(this, () => window.postMessage(Events.kEventScopeChange, Events.kScopeChangeTypePage)) }) 11 | document.getElementById('scope_document').addEventListener('click', function() { ifEnabled(this, () => window.postMessage(Events.kEventScopeChange, Events.kScopeChangeTypeDocument)) }) 12 | 13 | document.getElementById('action_replace').addEventListener('click', function() { notifyActionRequested(this, Events.kButtonPressReplace) }) 14 | document.getElementById('action_find_next').addEventListener('click', function() { notifyActionRequested(this, Events.kButtonPressFindNext) }) 15 | document.getElementById('action_replace_all').addEventListener('click', function() { notifyActionRequested(this, Events.kButtonPressReplaceAll) }) 16 | document.getElementById('action_cancel').addEventListener('click', () => window.postMessage(Events.kEventButtonPress, Events.kButtonPressCancel)) 17 | 18 | document.getElementById('input_find').addEventListener('keyup', function() { setActionButtonsState(this.value) } ) 19 | 20 | document.getElementById('toggle_case').addEventListener('click', function() { toggleIndividualActiveState(this) }) 21 | document.getElementById('toggle_word').addEventListener('click', function() { toggleIndividualActiveState(this) }) 22 | document.getElementById('toggle_layer').addEventListener('click', function() { toggleIndividualActiveState(this) }) 23 | 24 | function getFindText() { 25 | return document.getElementById('input_find').value 26 | } 27 | 28 | function getReplaceText() { 29 | return document.getElementById('input_replace').value 30 | } 31 | 32 | /** 33 | * Notifies the main process that an action has been requested 34 | * @param {*} el The element the action took place on 35 | * @param {*} event The event to fire 36 | */ 37 | function notifyActionRequested(el, event) { 38 | ifEnabled(el, () => 39 | window.postMessage( 40 | Events.kEventButtonPress, 41 | event, 42 | getFindText(), 43 | getReplaceText(), 44 | isActive(document.getElementById('toggle_case')), 45 | isActive(document.getElementById('toggle_word')), 46 | isActive(document.getElementById('toggle_layer')), 47 | ) 48 | ) 49 | } 50 | 51 | /** 52 | * 53 | * @param {*} el 54 | */ 55 | function isActive(el) { 56 | if (typeof el === 'string') { 57 | el = document.getElementById(el) 58 | } 59 | 60 | return el.getAttribute('class').includes('active') 61 | } 62 | 63 | /** 64 | * For any element with an ID prefix of 'toggle_', searches 65 | * the class names and adds or removes the 'active' style 66 | * based on its presence 67 | * @param {*} el 68 | */ 69 | function toggleIndividualActiveState(el) { 70 | // Only process 'toggle_' elements 71 | if (el.getAttribute('id').includes('toggle_')) { 72 | var style = el.getAttribute('class') 73 | // Remove active if present, otherwise include it 74 | if (style.includes('active')) { 75 | style = style.split('active').map(v => v.trim()).join(' ') 76 | el.setAttribute('class', style) 77 | } else { 78 | el.setAttribute('class', style + ' active') 79 | } 80 | // Notify main process a re-scan is in order 81 | window.onLayerTextChanged() 82 | } 83 | } 84 | 85 | /** 86 | * Based on the presense of text or not, manages the enabled/disabled state of the action buttons 87 | * @param {string} text Current value of find input 88 | */ 89 | function setActionButtonsState(text) { 90 | // Take classnames, filter out disabled then set accordingly to state 91 | var spliter = (classNames, active) => { 92 | var style = classNames.split(' ').filter(cls => cls !== 'disabled').join(' ') 93 | if (!active) { 94 | style += ' disabled' 95 | } 96 | return style 97 | } 98 | // Based on the current styles and active state, updates the attribute 99 | var updateStyles = (el, state) => { 100 | el.setAttribute('class', spliter(el.getAttribute('class'), state)) 101 | } 102 | 103 | var active = text && text.length > 0 104 | updateStyles(document.getElementById('action_find_next'), active) 105 | updateStyles(document.getElementById('action_replace'), active) 106 | updateStyles(document.getElementById('action_replace_all'), active) 107 | } 108 | 109 | /** 110 | * Checks to see if the element is disabled by way of class attributes. Executes 111 | * the function if not. 112 | * 113 | * @param {object} el HTML element 114 | * @param {function} fn Callback function if conditions are met 115 | */ 116 | function ifEnabled(el, fn) { 117 | if (el.getAttribute('class').includes('disabled') === false) { 118 | fn() 119 | } 120 | } 121 | 122 | /** 123 | * Sets the theme 124 | */ 125 | window.useTheme = function(theme) { 126 | var pathToTheme = '../styles.light.css' 127 | if (theme.toLowerCase() === 'dark') { 128 | pathToTheme = '../styles.dark.css' 129 | } 130 | var style = document.createElement('link') 131 | style.rel = 'stylesheet' 132 | style.type = 'text/css' 133 | style.href = pathToTheme 134 | document.head.appendChild(style) 135 | } 136 | 137 | window.onLayerTextChanged = function() { 138 | window.postMessage(Events.kEventTextChanged) 139 | } 140 | 141 | window.setActiveScopes = function(scopes) { 142 | var scopeMap = { 143 | [Events.kScopeChangeTypeDocument]: 'scope_document', 144 | [Events.kScopeChangeTypePage]: 'scope_page', 145 | [Events.kScopeChangeTypeArtboard]: 'scope_artboard', 146 | [Events.kScopeChangeTypeLayer]: 'scope_layer' 147 | } 148 | var activeScopes = scopes.split(',').reduce((accum, next) => { 149 | accum[next] = scopeMap[next] 150 | return accum 151 | },{}) 152 | var inactiveScopes = Object.keys(scopeMap).reduce((accum, next) => { 153 | if(!activeScopes[next]) { 154 | accum[next] = scopeMap[next] 155 | } 156 | return accum 157 | }, {}) 158 | 159 | // See if the current active scope selection can be maintained through this selection or if it 160 | // needs to fall back to document (default) 161 | var currentScopeKey = Object.keys(scopeMap).find(key => isActive(scopeMap[key])) 162 | var nextActiveScope = activeScopes[currentScopeKey] || scopeMap[Events.kScopeChangeTypeDocument] 163 | // Update button states 164 | var iconified = 'btn-iconified' 165 | Object.values(activeScopes).forEach(id => document.getElementById(id).setAttribute('class', iconified)) 166 | Object.values(inactiveScopes).forEach(id => document.getElementById(id).setAttribute('class', `${iconified} disabled`)) 167 | document.getElementById(nextActiveScope).setAttribute('class', `${iconified} active`) 168 | } 169 | 170 | /** 171 | * Sets the active class on the selected scope 172 | * @param {string} scopeEventType 173 | */ 174 | window.toggleSelectedBtnStyle = function(scopeEventType) { 175 | var activeBtnCollection = document.getElementsByClassName('btn-iconified active') 176 | // Reset the active class (should only be one entry in the collection) 177 | for (var i=0; i 0) { 23 | scopes.push(Events.kScopeChangeTypeLayer) 24 | 25 | const artboards = selectedLayers.map(layer => { 26 | return ARTBOARD_LIKE.includes(layer.type) ? layer : layer.getParentArtboard() 27 | }) 28 | const hasUndef = artboards.some(board => board === undefined) 29 | if (!hasUndef) { 30 | // We have just artboards. See if there's only one 31 | const uniqueBoards = artboards.reduce((accum, next) => { 32 | const has = accum.findIndex(board => board.id === next.id) !== -1 33 | if (!has) { 34 | accum.push(next) 35 | } 36 | return accum 37 | },[]) 38 | 39 | // We have some artboards, either one or equal # boards as selected layers 40 | if (uniqueBoards.length === 1 || uniqueBoards.length === selectedLayers.length) { 41 | scopes.push(Events.kScopeChangeTypeArtboard) 42 | // If only 1 layer is selected 43 | if (ARTBOARD_LIKE.includes(selectedLayers[0].type)) { 44 | scopes = scopes.filter(b => b !== Events.kScopeChangeTypeLayer) 45 | } 46 | } 47 | } 48 | } 49 | return scopes 50 | } 51 | 52 | /** 53 | * Triggered whenever the user changes which layers are selected in a document 54 | * 55 | * The action context for this action contains three keys: 56 | * document: the document that the change occurred in. 57 | * oldSelection: a list of the previously selected layers. 58 | * newSelection: a list of the newly selected layers. 59 | * @param {object} context 60 | */ 61 | export function onSelectionChanged(context) { 62 | if (isWebviewPresent(WEBVIEW_ID)) { 63 | const doc = sketch.fromNative(context.actionContext.document) 64 | const selectedLayers = toArray(context.actionContext.newSelection).map(native => sketch.fromNative(native)) 65 | const scopes = determineActiveScopes(selectedLayers) 66 | 67 | sendToWebview(WEBVIEW_ID, `setActiveScopes("${scopes.join(',')}")`) 68 | } 69 | } 70 | 71 | /** 72 | * This action is triggered when the contents of a Text Layer change. 73 | * 74 | * The action context for this action contains three keys: 75 | * old: The old contents of the Text Layer 76 | * new: The new contents of the Text Layer 77 | * layer: The layer that has changed 78 | * @param {object} context 79 | */ 80 | export function onTextChanged(context) { 81 | if (isWebviewPresent(WEBVIEW_ID)) { 82 | // Only if the contents have changed send the notification 83 | if (context.actionContext.new !== context.actionContext.old) { 84 | sendToWebview(WEBVIEW_ID, 'onLayerTextChanged()') 85 | } 86 | } 87 | } 88 | 89 | export default function() { 90 | const options = { 91 | identifier: WEBVIEW_ID, 92 | width: 436, 93 | height: 336, 94 | show: false, 95 | alwaysOnTop: true, 96 | maximizable: false, 97 | fullscreenable: false, 98 | } 99 | 100 | var browserWindow = new BrowserWindow(options) 101 | // only show the window when the page has loaded 102 | browserWindow.once('ready-to-show', () => { 103 | sendToWebview(WEBVIEW_ID, `useTheme("${UI.getTheme()}")`) 104 | 105 | const doc = Document.getSelectedDocument() 106 | const scopes = determineActiveScopes(doc.selectedLayers.layers) 107 | sendToWebview(WEBVIEW_ID, `setActiveScopes("${scopes.join(',')}")`) 108 | 109 | // Give a brief amount of time for styles to be applied in an effort to eliminiate flickering 110 | setTimeout(() => browserWindow.show(), 100) 111 | }) 112 | 113 | const webContents = browserWindow.webContents 114 | const ts = new Turnstile() 115 | let currentScope = Events.kScopeChangeTypeDocument 116 | 117 | /** 118 | * Handles change in scope selection on the browser view 119 | */ 120 | webContents.on(Events.kEventScopeChange, scopeType => { 121 | if (currentScope != scopeType) { 122 | webContents 123 | .executeJavaScript(`toggleSelectedBtnStyle("${scopeType}")`) 124 | .catch(console.error) 125 | } 126 | currentScope = scopeType 127 | scanner.markDirty() 128 | }) 129 | 130 | /** 131 | * Handle changes in text on the document by marking scanner as dirty 132 | */ 133 | webContents.on(Events.kEventTextChanged, () => { 134 | scanner.markDirty() 135 | }) 136 | 137 | /** 138 | * 139 | */ 140 | webContents.on(Events.kEventButtonPress, (eventType, findText, replaceText, isCaseSensitive, isWholeWord, isLayers) => { 141 | // Did they choose to cancel 142 | if (Events.kButtonPressCancel === eventType) { 143 | browserWindow.close() 144 | browserWindow = null 145 | return 146 | } 147 | 148 | const searchTerm = typeof findText === 'string' ? findText.trim() : null 149 | // Allow whitespace in replace text 150 | const replaceWith = replaceText || '' 151 | if (!searchTerm) { 152 | UI.message('Please enter search text') 153 | return 154 | } 155 | 156 | const doc = Document.getSelectedDocument() 157 | // Determine our source for searching 158 | let searchArea 159 | if (currentScope === Events.kScopeChangeTypeDocument) { 160 | searchArea = doc 161 | } else if (currentScope === Events.kScopeChangeTypePage) { 162 | searchArea = doc.selectedPage 163 | } else if (currentScope === Events.kScopeChangeTypeArtboard) { 164 | const artboards = doc.selectedLayers.map(layer => { 165 | return ARTBOARD_LIKE.includes(layer.type) ? layer : layer.getParentArtboard() 166 | }) 167 | const uniqueBoards = artboards.reduce((accum, next) => { 168 | const has = accum.findIndex(board => board.id === next.id) !== -1 169 | if (!has) { 170 | accum.push(next) 171 | } 172 | return accum 173 | },[]) 174 | searchArea = uniqueBoards 175 | } else if (currentScope === Events.kScopeChangeTypeLayer) { 176 | searchArea = doc.selectedLayers 177 | } 178 | 179 | // Rescan the document if necessary 180 | const sameTerm = ts.searchTerm === searchTerm 181 | if (!sameTerm || scanner.isDirty()) { 182 | try { 183 | const exp = isWholeWord ? `\\b${searchTerm}\\b` : searchTerm 184 | const re = new RegExp(exp, isCaseSensitive ? 'g' : 'gi') 185 | const layers = scanner.findTextLayers(searchArea, searchTerm, re, { 186 | isLayers 187 | }) 188 | 189 | UI.message(`Found ${layers.length} matching layer${layers.length === 1 ? '' : 's'}`) 190 | ts.setLayers(layers, searchTerm, re) 191 | } catch (e) { 192 | console.log(e) 193 | } 194 | } 195 | 196 | if (eventType === Events.kButtonPressFindNext) { 197 | const layer = ts.cycleToNextLayer() 198 | if (layer) { 199 | const page = layer.getParentPage() 200 | if (page && page !== doc.selectedPage) { 201 | doc.selectedPage = page 202 | } 203 | // Don't center when layers 204 | if (!isLayers) { 205 | doc.centerOnLayer(layer) 206 | } 207 | } else { 208 | UI.message(`Text not found: ${searchTerm}`) 209 | } 210 | } else if (eventType === Events.kButtonPressReplace) { 211 | ts.replaceCurrentLayer(replaceWith) 212 | } else if (eventType === Events.kButtonPressReplaceAll) { 213 | const num = ts.numLayers 214 | ts.replaceAllLayers(replaceWith) 215 | UI.message(`Replaced text on ${num} layer${num === 1 ? '' : 's'}`) 216 | } 217 | }) 218 | 219 | browserWindow.loadURL(require('../resources/webview.html')) 220 | } 221 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Find and Replace Text", 3 | "description": "Find and replace text throughout your Sketch document", 4 | "author": "Chris Wetterman", 5 | "authorEmail": "chris.wetterman.web@me.com", 6 | "homepage": "https://github.com/chriswetterman/sketch-find-and-replace-text", 7 | "identifier": "com.chriswetterman.sketch.find-and-replace-text-plugin", 8 | "compatibleVersion": 53, 9 | "bundleVersion": 1, 10 | "commands": [ 11 | { 12 | "name": "🕵🏻‍ Find and Replace Text", 13 | "identifier": "find-and-replace-text-identifier", 14 | "script": "./find-and-replace-text-command.js", 15 | "shortcut": "cmd shift f", 16 | "handlers" : { 17 | "actions": { 18 | "SelectionChanged.finish": "onSelectionChanged", 19 | "TextChanged.finish": "onTextChanged" 20 | }, 21 | "run": "onRun" 22 | } 23 | } 24 | ], 25 | "menu": { 26 | "title": "Find and Replace Text", 27 | "items": [ 28 | "find-and-replace-text-identifier" 29 | ], 30 | "isRoot": true 31 | }, 32 | "icon": "icon.png" 33 | } 34 | -------------------------------------------------------------------------------- /src/scanner.js: -------------------------------------------------------------------------------- 1 | import sketch from 'sketch'; 2 | import { CanvasElement, SymbolOverride, Layer } from './elements'; 3 | 4 | // All known Sketch object types containing pages or layers properties 5 | const supportedNestedObjectTypes = [ 6 | String(sketch.Types.Document), 7 | String(sketch.Types.Artboard), 8 | String(sketch.Types.Page), 9 | String(sketch.Types.Group), 10 | String(sketch.Types.SymbolMaster), 11 | ] 12 | 13 | let dirty = true 14 | 15 | function overAndOverAgain(element, term, re) { 16 | if (!term || term.length === 0) return [] 17 | if (!element) return [] 18 | dirty = false 19 | 20 | const type = element.type 21 | if (type === String(sketch.Types.Text)) { 22 | if (element.text.match(new RegExp(re))) { 23 | return [new CanvasElement(element)] 24 | } 25 | } 26 | else if (type === String(sketch.Types.SymbolInstance)) { 27 | // Iterate through overrides 28 | return element.overrides.reduce((accum, next) => { 29 | if (next.editable && !next.isDefault && typeof next.value === 'string') { 30 | if (next.value.match(new RegExp(re))) { 31 | accum.push(new SymbolOverride(element, next)) 32 | } 33 | } 34 | return accum 35 | },[]) 36 | } 37 | // White-list of known types with layers for eaze of compatibility reasons 38 | else if (supportedNestedObjectTypes.includes(type)) { 39 | const data = element.pages || element.layers 40 | return data.reduce((accum, datum) => { 41 | const r = overAndOverAgain(datum, term, re) 42 | return [...accum, ...r] 43 | }, []) 44 | } 45 | // Collection of selected layers 46 | else if (element.reduce) { 47 | return element.reduce((accum, datum) => { 48 | const r = overAndOverAgain(datum, term, re) 49 | return [...accum, ...r] 50 | }, []) 51 | } 52 | 53 | return [] 54 | } 55 | 56 | 57 | function layerNamesOverAndOverAgain (element, term, re) { 58 | if (!term || term.length === 0) return [] 59 | if (!element) return [] 60 | dirty = false 61 | 62 | const type = element.type 63 | let matches = [] 64 | // If this layer matches, hold onto it 65 | if (element.name && element.name.match(re)) { 66 | matches.push(new Layer(element)) 67 | } 68 | 69 | // White-list of known types with layers for eaze of compatibility reasons 70 | if (supportedNestedObjectTypes.includes(type)) { 71 | const data = element.pages || element.layers 72 | return data.reduce((accum, datum) => { 73 | const r = layerNamesOverAndOverAgain(datum, term, re) 74 | return [...accum, ...r] 75 | }, matches) 76 | } 77 | // Collection of selected layers 78 | else if (element.reduce) { 79 | return element.reduce((accum, datum) => { 80 | const r = layerNamesOverAndOverAgain(datum, term, re) 81 | return [...accum, ...r] 82 | }, matches) 83 | } 84 | 85 | return matches 86 | } 87 | 88 | 89 | export default { 90 | /** 91 | * Marks scan state as dirty 92 | */ 93 | markDirty: () => { dirty = true }, 94 | /** 95 | * Returns the dirty state 96 | */ 97 | isDirty: () => dirty, 98 | 99 | /** 100 | * Accepts any Sketch object type, scanning all sublayers and returning an array 101 | * of text layers matching term 102 | * @param {object} element 103 | * @param {string} term 104 | * @param {object} re 105 | */ 106 | findTextLayers: function(element, term, re, options) { 107 | const recurse = options.isLayers ? layerNamesOverAndOverAgain : overAndOverAgain 108 | const results = recurse(element, term, re) 109 | // If multiple layers were selected we could have dupes 110 | const unique = results.reduce((accum, next) => { 111 | const has = accum.findIndex(el => el.raw.id === next.raw.id) !== -1 112 | if (!has) { 113 | accum.push(next) 114 | } 115 | return accum 116 | }, []) 117 | 118 | return unique 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/turnstile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default class Turnstile { 4 | 5 | constructor() { 6 | this._layers = [] 7 | this._searchTerm = null 8 | this._re = new RegExp() 9 | } 10 | 11 | /** 12 | * Returns the term used with the current set of Layers 13 | */ 14 | get searchTerm() { return this._searchTerm } 15 | 16 | /** 17 | * Returns the number of Layers matching the search term 18 | */ 19 | get numLayers() { return this._layers.length } 20 | 21 | /** 22 | * Sets the Layers on the turnstile 23 | * @param {CanvasElement[]} layers 24 | * @param {string} term 25 | * @param {Object} re 26 | */ 27 | setLayers(layers, term, re) { 28 | this._layers = Array.isArray(layers) ? layers : [] 29 | this._searchTerm = term || null 30 | this._re = re 31 | } 32 | 33 | /** 34 | * Cycles through the matching layers, one by one 35 | */ 36 | cycleToNextLayer() { 37 | if (this._layers.length > 0) { 38 | const l = this._layers.shift() 39 | this._layers.push(l) 40 | return l.raw 41 | } 42 | return null 43 | } 44 | 45 | /** 46 | * Replaces the the active layer with replacement string 47 | * @param {string} rpl 48 | */ 49 | replaceCurrentLayer(rpl) { 50 | if (this._layers.length === 0) { 51 | return 52 | } 53 | 54 | const l = this._layers.pop() 55 | l.text = l.text.replace(this._re, rpl) 56 | } 57 | 58 | /** 59 | * Replaces all layers matching the replacement string 60 | * @param {string} rpl 61 | */ 62 | replaceAllLayers(rpl) { 63 | while(this._layers.length > 0) { 64 | this.replaceCurrentLayer(rpl) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /webpack.skpm.config.js: -------------------------------------------------------------------------------- 1 | var CopyWebpackPlugin = require('copy-webpack-plugin') 2 | 3 | module.exports = function (config) { 4 | config.module.rules.push({ 5 | test: /\.(html)$/, 6 | use: [{ 7 | loader: "@skpm/extract-loader", 8 | }, 9 | { 10 | loader: "html-loader", 11 | options: { 12 | attrs: [ 13 | 'img:src', 14 | 'link:href' 15 | ], 16 | interpolate: true, 17 | }, 18 | }, 19 | ] 20 | }) 21 | config.module.rules.push({ 22 | test: /\.(css)$/, 23 | use: [{ 24 | loader: "@skpm/extract-loader", 25 | }, 26 | { 27 | loader: "css-loader", 28 | }, 29 | ] 30 | }) 31 | // Do some extra lifting when building the webview 32 | if (config.entry && config.entry.includes('webview.js')) { 33 | config.plugins.push( 34 | new CopyWebpackPlugin([ 35 | { from: './resources/styles.light.css', to: config.output.path }, 36 | { from: './resources/styles.dark.css', to: config.output.path }, 37 | { from: './assets/icon.png', to: config.output.path }, 38 | ])) 39 | } 40 | } 41 | --------------------------------------------------------------------------------