├── .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 |
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/icon.png
--------------------------------------------------------------------------------
/assets/layersIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/assets/pageIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/chriswetterman/sketch-find-and-replace-text/33c4ba39fa2ef40feaa9c4537b195167012ec467/assets/pageIcon.png
--------------------------------------------------------------------------------
/assets/pageIcon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
16 |
17 |
18 |
Find:
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | Replace with:
30 |
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 |
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 |
--------------------------------------------------------------------------------