@standardnotes/bold-editor 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.babelrc +11 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc +29 -0
  4. package/CHANGELOG.md +82 -0
  5. package/LICENSE +661 -0
  6. package/README.md +98 -0
  7. package/app/App.js +18 -0
  8. package/app/components/Editor.js +415 -0
  9. package/app/main.js +8 -0
  10. package/app/stylesheets/main.scss +272 -0
  11. package/dist/dist.css +1 -0
  12. package/dist/dist.min.js +2 -0
  13. package/dist/dist.min.js.LICENSE.txt +42 -0
  14. package/dist/filesafe-js/EncryptionWorker.js +2 -0
  15. package/dist/filesafe-js/EncryptionWorker.js.LICENSE.txt +5 -0
  16. package/dist/index.html +1 -0
  17. package/dist/vendor.css +6 -0
  18. package/dist/vendor.js +1 -0
  19. package/editor.index.ejs +14 -0
  20. package/editor_bar.png +0 -0
  21. package/ext.json.sample +9 -0
  22. package/package.json +54 -0
  23. package/redactor/plugins/alignment/alignment.js +55 -0
  24. package/redactor/plugins/alignment/alignment.min.js +1 -0
  25. package/redactor/plugins/counter/counter.js +76 -0
  26. package/redactor/plugins/counter/counter.min.js +1 -0
  27. package/redactor/plugins/filesafe/filesafe.js +70 -0
  28. package/redactor/plugins/filesafe/filesafe.min.js +70 -0
  29. package/redactor/plugins/fontcolor/fontcolor.js +184 -0
  30. package/redactor/plugins/fontcolor/fontcolor.min.js +1 -0
  31. package/redactor/plugins/fontfamily/fontfamily.js +59 -0
  32. package/redactor/plugins/fontfamily/fontfamily.min.js +1 -0
  33. package/redactor/plugins/fontsize/fontsize.js +58 -0
  34. package/redactor/plugins/fontsize/fontsize.min.js +1 -0
  35. package/redactor/plugins/imagemanager/imagemanager.js +82 -0
  36. package/redactor/plugins/imagemanager/imagemanager.min.js +1 -0
  37. package/redactor/plugins/inlinestyle/inlinestyle.css +34 -0
  38. package/redactor/plugins/inlinestyle/inlinestyle.js +62 -0
  39. package/redactor/plugins/inlinestyle/inlinestyle.min.css +1 -0
  40. package/redactor/plugins/inlinestyle/inlinestyle.min.js +1 -0
  41. package/redactor/plugins/specialchars/specialchars.js +78 -0
  42. package/redactor/plugins/specialchars/specialchars.min.js +1 -0
  43. package/redactor/plugins/table/table.js +477 -0
  44. package/redactor/plugins/table/table.min.js +1 -0
  45. package/redactor/plugins/textdirection/textdirection.js +44 -0
  46. package/redactor/plugins/textdirection/textdirection.min.js +1 -0
  47. package/redactor/plugins/textexpander/textexpander.js +64 -0
  48. package/redactor/plugins/textexpander/textexpander.min.js +1 -0
  49. package/redactor/plugins/variable/variable.css +23 -0
  50. package/redactor/plugins/variable/variable.js +222 -0
  51. package/redactor/plugins/variable/variable.min.css +1 -0
  52. package/redactor/plugins/variable/variable.min.js +1 -0
  53. package/redactor/src/redactor.min.css +1 -0
  54. package/redactor/src/redactor.min.js +1 -0
  55. package/webpack.config.js +82 -0
  56. package/webpack.dev.js +20 -0
  57. package/webpack.prod.js +11 -0
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # Bold Editor
2
+
3
+ The Bold Editor is a Standard Notes derived editor that offers text formatting and FileSafe integration.
4
+
5
+ ![Over 21 different text formatting features are available.](editor_bar.png)
6
+
7
+ ## Local Installation
8
+
9
+ Get a full working copy of the editor (with FileSafe) for development.
10
+
11
+ 1. Clone the [bold-editor](https://github.com/standardnotes/bold-editor) and [filesafe-embed](https://github.com/standardnotes/filesafe-embed) repositories from GitHub.
12
+
13
+ 2. Ensure that either the Standard Notes desktop app is available for use or the web app is accessible. Use both locally or with an Extended account (or the extension will not load).
14
+
15
+ 3. In the `bold-editor` folder, edit the `package.json` file under `devDependencies` to use the local `filesafe-embed`:
16
+ ```json
17
+ "filesafe-embed": "~/folder_with_both_repositories/filesafe-embed",
18
+ ```
19
+
20
+ 4. Run `npm i` in both the `bold-editor` and `filesafe-embed` folders to install the required dependencies.
21
+ - If there are errors, delete the `package-lock.json` file and `node_modules` folder. Then run `npm i` again. ([source](https://stackoverflow.com/questions/48298361/npm-install-failed-at-the-node-sass4-5-0-postinstall-script))
22
+
23
+ 5. Edit `app/index.html` for use locally:
24
+ - comment out lines under 'Production'
25
+ - uncomment lines under 'Development'
26
+
27
+ ```html
28
+ <!-- Development -->
29
+ <script type="text/javascript" src="redactor.min.js"></script>
30
+ <script type="text/javascript" src="app.min.js"></script>
31
+
32
+ <!-- Production -->
33
+ <!--<script type="text/javascript" src="dist.min.js"></script>-->
34
+ ```
35
+ 6. Run `npm run build` to build the files.
36
+ 6. Run `npm i -g http-server` to install a simple local server to host the extension.
37
+ 7. Choose between webpack Watch Mode and webpack-dev-server for development and follow the corresponding instructions.
38
+
39
+ ## Development with webpack Watch Mode (recommended)
40
+
41
+ Start by following the instructions here: https://docs.standardnotes.org/extensions/local-setup. Included in the repository is an `ext.json.sample` file that can be used in the setup.
42
+
43
+ This will setup a local server from which the bold-editor can be imported via the desktop app or the web app. You should be able to use the bold-editor now.
44
+
45
+ However, this will not allow for easy development because the app will not automatically build to the dist folder. We will use [webpack](https://webpack.js.org/guides/development/#using-watch-mode) for this.
46
+
47
+ 1. Use `npm run watch` to automatically build files.
48
+ - There should be an existing console open that is running `http-server`
49
+ - Open a new console for `npm run watch`
50
+
51
+ 2. Disable the cache on the desktop app/web app.
52
+ - We want to ensure that the latest build is always loaded when the app is refreshed
53
+ - Open devtools (`Ctrl+Shift+i`) and go to `Network`
54
+ - Check `Disable cache`
55
+ - On some systems, devtools must be kept open for this to work
56
+
57
+ 3. Make some changes to `Editor.js`, reload the desktop or web app, and your changes will show up.
58
+
59
+ ## Development with webpack-dev-server
60
+
61
+ *Note that this method only actively builds `app.min.js`.*
62
+
63
+ The steps are similar to the webpack Watch Mode, differences are listed below:
64
+
65
+ - The `ext.json` file belongs in the `dist` folder
66
+ - Update the url to `http://localhost:8080`
67
+ - `npm run start` to use the webpack-dev-server.
68
+
69
+ Disable the cache as in the webpack Watch Mode. Reload may be required to see changes in action.
70
+
71
+ ## Production
72
+
73
+ In production environments, check that the `index.html` file is configured as follows:
74
+
75
+ ```html
76
+ <!-- Development -->
77
+ <!-- <script type="text/javascript" src="redactor.min.js"></script>
78
+ <script type="text/javascript" src="app.min.js"></script> -->
79
+
80
+ <!-- Production -->
81
+ <script type="text/javascript" src="dist.min.js"></script>
82
+ ```
83
+
84
+ `dist.min.js` is built via `grunt`.
85
+
86
+ The CSS is also built with grunt, so webpack-dev-server will not be able to reload it. You must run `npm run build` anytime you change the CSS.
87
+
88
+ ## Support
89
+
90
+ Please open a new issue and the Standard Notes team will take a look as soon as we can. For more information on editors, refer to the following link:
91
+
92
+ - Standard Notes Help: [What are editors?](https://standardnotes.org/help/77/what-are-editors)
93
+
94
+ Known issue: ordered lists, unordered lists, and tables seem to ignore any font preference you apply to it.
95
+
96
+ ## License
97
+
98
+ [GNU AGPL v3.0](https://choosealicense.com/licenses/agpl-3.0/)
package/app/App.js ADDED
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import Editor from '@Components/Editor';
3
+
4
+ export default class App extends React.Component {
5
+ constructor(props) {
6
+ super(props);
7
+ }
8
+
9
+ render() {
10
+ return (
11
+ <div id="editor-container">
12
+ <div key="editor" id="editor">
13
+ <Editor />
14
+ </div>
15
+ </div>
16
+ );
17
+ }
18
+ }
@@ -0,0 +1,415 @@
1
+ import React from 'react';
2
+ import FilesafeEmbed from 'filesafe-embed';
3
+ import EditorKit from '@standardnotes/editor-kit';
4
+ import DOMPurify from 'dompurify';
5
+ import { SKAlert } from 'sn-stylekit';
6
+
7
+ // Not used directly here, but required to be imported so that it is included
8
+ // in dist file.
9
+ // Note that filesafe-embed also imports filesafe-js, but conditionally, so
10
+ // it's not included in it's own dist files.
11
+ // eslint-disable-next-line no-unused-vars
12
+ import Filesafe from 'filesafe-js';
13
+
14
+ export default class Editor extends React.Component {
15
+
16
+ constructor(props) {
17
+ super(props);
18
+ this.state = {};
19
+ this.alert = null;
20
+ this.renderNote = false;
21
+ this.isNoteLocked = false;
22
+ }
23
+
24
+ componentDidMount() {
25
+ this.configureEditorKit();
26
+ this.configureEditor();
27
+ }
28
+
29
+ configureEditorKit() {
30
+ // EditorKit is a wrapper on top of the component manager to make it
31
+ // easier to build editors. As such, it very general and does not know
32
+ // how the functions are implemented, just that they are needed. It is
33
+ // up to the Bold Editor wrapper to implement these important functions.
34
+ const delegate = {
35
+ insertRawText: (rawText) => {
36
+ this.redactor.insertion.insertHtml(rawText);
37
+ },
38
+ handleRequestForContentHeight: () => {
39
+ return undefined
40
+ },
41
+ preprocessElement: (element) => {
42
+ // Convert inserting element to format Redactor wants.
43
+ // This will wrap img elements, for example, in a figure element.
44
+ // We also want to persist attributes from the inserting element.
45
+ const cleaned = this.redactor.cleaner.input(element.outerHTML);
46
+ const newElement = $R.dom(cleaned).nodes[0];
47
+
48
+ for (const attribute of element.attributes) {
49
+ newElement.setAttribute(attribute.nodeName, attribute.nodeValue);
50
+ }
51
+
52
+ return newElement;
53
+ },
54
+ insertElement: (element, inVicinityOfElement, insertionType) => {
55
+ // When inserting elements via dom manipulation, it doesnt update the
56
+ // source code view. So when you insert this element, open the code
57
+ // view, and close it, the element will be gone. The only way it works
58
+ // is if we use the proper redactor.insertion API, but I haven't found
59
+ // a good way to use that API for inserting text at a given position.
60
+ // There is 'insertToOffset', but where offset is the index of the
61
+ // plaintext, but I haven't found a way to map the adjacentTo element
62
+ // to a plaintext offset. So for now this bug will persist.
63
+
64
+ // insertionType can be either 'afterend' or 'child'
65
+
66
+ if (inVicinityOfElement) {
67
+ if (insertionType == 'afterend') {
68
+ inVicinityOfElement.insertAdjacentElement('afterend', element);
69
+ } else if (insertionType == 'child') {
70
+ // inVicinityOfElement.appendChild(element) doesn't work for some
71
+ // reason when inserting videos.
72
+ inVicinityOfElement.after(element);
73
+ }
74
+ } else {
75
+ this.redactor.insertion.insertHtml(element.outerHTML);
76
+ }
77
+ },
78
+ getElementsBySelector: (selector) => {
79
+ return this.redactor.editor.getElement().find(selector).nodes;
80
+ },
81
+ getCurrentLineText: () => {
82
+ // Returns the text content of the node where the cursor currently is.
83
+ // Typically a paragraph if no formatter, otherwise the closest
84
+ // formatted element, or null if there is no text content.
85
+ const node = this.redactor.selection.getCurrent();
86
+ return node.textContent;
87
+ },
88
+ getPreviousLineText: () => {
89
+ // Returns the text content of the previous node, unless there is no
90
+ // previous node, in which case it returns the falsy value.
91
+ const currentElement = this.redactor.selection.getElement();
92
+ const previousSibling = currentElement.previousSibling;
93
+ return previousSibling && previousSibling.textContent;
94
+ },
95
+ replaceText: ({ regex, replacement, previousLine }) => {
96
+ const marker = this.redactor.marker.insert('start');
97
+ let node;
98
+ if (previousLine) {
99
+ node = this.redactor.selection.getElement().previousSibling;
100
+ } else {
101
+ node = marker.previousSibling;
102
+ }
103
+
104
+ // If we're searching the previous line, previousSibling may sometimes
105
+ // be null.
106
+ if (!node) {
107
+ return;
108
+ }
109
+
110
+ let nodeText = node.textContent;
111
+ // Remove our match from this element by replacing with empty string.
112
+ // We'll add in our actual replacement as a new element
113
+ nodeText = nodeText.replace(/&nbsp;/, ' ');
114
+ nodeText = nodeText.replace(regex, '').replace(/\s$/, '').trim();
115
+ if (nodeText.length == 0) {
116
+ node.remove();
117
+ } else {
118
+ node.textContent = nodeText;
119
+ }
120
+
121
+ this.redactor.insertion.insertHtml(replacement, 'start');
122
+ this.redactor.selection.restoreMarkers();
123
+ },
124
+ clearUndoHistory: () => {
125
+ // Called when switching notes to prevent history mixup.
126
+ $R('#editor', 'module.buffer.clear');
127
+ },
128
+ onNoteValueChange: async (note) => {
129
+ this.renderNote = await this.shouldRenderNote(note);
130
+ this.isNoteLocked = this.getNoteLockState(note);
131
+
132
+ document.getElementById('editor').setAttribute(
133
+ 'spellcheck',
134
+ JSON.stringify(note.content.spellcheck)
135
+ );
136
+
137
+ this.scrollToTop();
138
+ },
139
+ setEditorRawText: (rawText) => {
140
+ // Disabling read-only mode so that we can use source.setCode
141
+ this.disableReadOnly();
142
+
143
+ if (!this.renderNote) {
144
+ $R('#editor', 'source.setCode', '');
145
+ this.enableReadOnly();
146
+ return;
147
+ }
148
+
149
+ // Called when the Bold Editor is loaded, when switching to a Bold
150
+ // Editor note, or when uploading files, maybe in more places too.
151
+ const cleaned = this.redactor.cleaner.input(rawText);
152
+ $R('#editor', 'source.setCode', cleaned);
153
+
154
+ if (this.isNoteLocked) {
155
+ this.enableReadOnly();
156
+ } else {
157
+ this.disableReadOnly();
158
+ }
159
+ }
160
+ };
161
+
162
+ this.editorKit = new EditorKit(delegate, {
163
+ mode: 'html',
164
+ supportsFileSafe: true,
165
+ // Redactor has its own debouncing, so we'll set ours to 0
166
+ coallesedSavingDelay: 0
167
+ });
168
+ }
169
+
170
+ async configureEditor() {
171
+ // We need to set this as a window variable so that the filesafe plugin
172
+ // can interact with this object passing it as an opt for some reason
173
+ // strips any functions off the objects.
174
+ const filesafeInstance = await this.editorKit.getFileSafe();
175
+ window.filesafe_params = {
176
+ embed: FilesafeEmbed,
177
+ client: filesafeInstance
178
+ };
179
+ this.redactor = $R('#editor', {
180
+ styles: true,
181
+ toolbarFixed: true, // sticky toolbar
182
+ tabAsSpaces: 2, // currently tab only works if you use spaces.
183
+ tabKey: true, // explicitly set tabkey for editor use, not for focus.
184
+ linkSize: 20000, // redactor default is 30, which truncates the link.
185
+ buttonsAdd: ['filesafe'],
186
+ buttons: [
187
+ 'bold', 'italic', 'underline', 'deleted', 'format', 'fontsize',
188
+ 'fontfamily', 'fontcolor', 'filesafe', 'link', 'lists', 'alignment',
189
+ 'line', 'redo', 'undo', 'indent', 'outdent', 'textdirection', 'html'
190
+ ],
191
+ plugins: [
192
+ 'filesafe', 'fontsize', 'fontfamily', 'fontcolor', 'alignment',
193
+ 'table', 'inlinestyle', 'textdirection'
194
+ ],
195
+ fontfamily: [
196
+ 'Arial', 'Helvetica', 'Georgia', 'Times New Roman', 'Trebuchet MS',
197
+ 'Monospace'
198
+ ],
199
+ callbacks: {
200
+ changed: (html) => {
201
+ if (this.isNoteLocked || this.redactor.isReadOnly() || !this.renderNote) {
202
+ return;
203
+ }
204
+ // I think it's already cleaned so we don't need to do this.
205
+ // let cleaned = this.redactor.cleaner.output(html);
206
+ this.editorKit.onEditorValueChanged(html);
207
+ },
208
+ pasted: (_nodes) => {
209
+ this.editorKit.onEditorPaste();
210
+ },
211
+ image: {
212
+ resized: (image) => {
213
+ // Underlying html will change, triggering save event.
214
+ // New img dimensions need to be copied over to figure element.
215
+ const img = image.nodes[0];
216
+ const fig = img.parentNode;
217
+ fig.setAttribute('width', img.getAttribute('width'));
218
+ fig.setAttribute('height', img.getAttribute('height'));
219
+ }
220
+ }
221
+ },
222
+ imageEditable: false,
223
+ imageCaption: false,
224
+ imageLink: false,
225
+ imageResizable: true, // requires image to be wrapped in a figure.
226
+ imageUpload: (formData, files, _event) => {
227
+ // Called when images are pasted from the clipboard too.
228
+ this.onEditorFilesDrop(files);
229
+ }
230
+ });
231
+
232
+ this.redactor.editor.getElement().on('keyup.textsearcher', (event) => {
233
+ const key = event.which;
234
+ this.editorKit.onEditorKeyUp({
235
+ key,
236
+ isSpace: key == this.redactor.keycodes.SPACE,
237
+ isEnter: key == this.redactor.keycodes.ENTER
238
+ });
239
+ });
240
+
241
+ // "Set the focus to the editor layer to the end of the content."
242
+ // Doesn't work because setEditorRawText is called when loading a note and
243
+ // it doesn't save the caret location, so focuses to beginning.
244
+ if (!this.redactor.editor.isEmpty()) {
245
+ this.redactor.editor.endFocus();
246
+ }
247
+ }
248
+
249
+ onEditorFilesDrop(files) {
250
+ if (!this.editorKit.canUseFileSafe()) {
251
+ return;
252
+ }
253
+
254
+ if (!this.editorKit.canUploadFiles()) {
255
+ // Open filesafe modal
256
+ this.redactor.plugin.filesafe.open();
257
+ return;
258
+ }
259
+
260
+ for (const file of files) {
261
+ // Observers in EditorKitInternal.js will handle successful upload
262
+ this.editorKit.uploadJSFileObject(file).then((descriptor) => {
263
+ if (!descriptor || !descriptor.uuid) {
264
+ // alert("File failed to upload. Please try again");
265
+ }
266
+ });
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Checks if HTML is safe to render.
272
+ */
273
+ checkIfUnsafeContent(renderedHtml) {
274
+ const sanitizedHtml = DOMPurify.sanitize(renderedHtml, {
275
+ /**
276
+ * We don't need script or style tags.
277
+ */
278
+ FORBID_TAGS: ['script', 'style'],
279
+ /**
280
+ * XSS payloads can be injected via these attributes.
281
+ */
282
+ FORBID_ATTR: [
283
+ 'onerror',
284
+ 'onload',
285
+ 'onunload',
286
+ 'onclick',
287
+ 'ondblclick',
288
+ 'onmousedown',
289
+ 'onmouseup',
290
+ 'onmouseover',
291
+ 'onmousemove',
292
+ 'onmouseout',
293
+ 'onfocus',
294
+ 'onblur',
295
+ 'onkeypress',
296
+ 'onkeydown',
297
+ 'onkeyup',
298
+ 'onsubmit',
299
+ 'onreset',
300
+ 'onselect',
301
+ 'onchange'
302
+ ]
303
+ });
304
+
305
+ /**
306
+ * Create documents from both the sanitized string and the rendered string.
307
+ * This will allow us to compare them, and if they are not equal
308
+ * (i.e: do not contain the same properties, attributes, inner text, etc)
309
+ * it means something was stripped.
310
+ */
311
+ const renderedDom = new DOMParser().parseFromString(renderedHtml, 'text/html');
312
+ const sanitizedDom = new DOMParser().parseFromString(sanitizedHtml, 'text/html');
313
+ return !renderedDom.isEqualNode(sanitizedDom);
314
+ }
315
+
316
+ async showUnsafeContentAlert() {
317
+ const text = 'We’ve detected that this note contains a script or code snippet which may be unsafe to execute. ' +
318
+ 'Scripts executed in the editor have the ability to impersonate as the editor to Standard Notes. ' +
319
+ 'Press Continue to mark this script as safe and proceed, or Cancel to avoid rendering this note.';
320
+
321
+ return new Promise((resolve) => {
322
+ this.alert = new SKAlert({
323
+ title: null,
324
+ text,
325
+ buttons: [
326
+ {
327
+ text: 'Cancel',
328
+ style: 'neutral',
329
+ action: function() {
330
+ resolve(false);
331
+ },
332
+ },
333
+ {
334
+ text: 'Continue',
335
+ style: 'danger',
336
+ action: function() {
337
+ resolve(true);
338
+ },
339
+ },
340
+ ]
341
+ });
342
+ this.alert.present();
343
+ });
344
+ }
345
+
346
+ setTrustUnsafeContent(note) {
347
+ this.editorKit.saveItemWithPresave(note, () => {
348
+ note.clientData = {
349
+ ...note.clientData,
350
+ trustUnsafeContent: true
351
+ };
352
+ });
353
+ }
354
+
355
+ enableReadOnly() {
356
+ if (this.redactor.isReadOnly()) {
357
+ return;
358
+ }
359
+ $R('#editor', 'enableReadOnly');
360
+ }
361
+
362
+ disableReadOnly() {
363
+ if (!this.redactor.isReadOnly()) {
364
+ return;
365
+ }
366
+ $R('#editor', 'disableReadOnly');
367
+ }
368
+
369
+ scrollToTop() {
370
+ window.scroll(0, 0);
371
+ }
372
+
373
+ async shouldRenderNote(noteItem) {
374
+ this.dismissUnsafeContentAlerts();
375
+
376
+ const isUnsafeContent = this.checkIfUnsafeContent(noteItem.content.text);
377
+ const trustUnsafeContent = noteItem.clientData['trustUnsafeContent'] ?? false;
378
+
379
+ if (!isUnsafeContent) {
380
+ return true;
381
+ }
382
+
383
+ if (isUnsafeContent && trustUnsafeContent) {
384
+ return true;
385
+ }
386
+
387
+ const result = await this.showUnsafeContentAlert();
388
+ if (result) {
389
+ this.setTrustUnsafeContent(noteItem);
390
+ }
391
+
392
+ return result;
393
+ }
394
+
395
+ dismissUnsafeContentAlerts() {
396
+ try {
397
+ if (this.alert) {
398
+ this.alert.dismiss();
399
+ }
400
+ this.alert = null;
401
+ } catch (e) {
402
+ console.warn('Trying to dismiss an alert that does not exist anymore.');
403
+ }
404
+ }
405
+
406
+ getNoteLockState(note) {
407
+ return note.content.appData['org.standardnotes.sn']['locked'] ?? false;
408
+ }
409
+
410
+ render() {
411
+ return (
412
+ <div key="editor" className={'sn-component'} />
413
+ );
414
+ }
415
+ }
package/app/main.js ADDED
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import App from './App';
4
+
5
+ ReactDOM.render(
6
+ <App />,
7
+ document.body.appendChild(document.createElement('div'))
8
+ );