@xh/hoist 79.0.0-SNAPSHOT.1765828486265 → 79.0.0-SNAPSHOT.1765833120742

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.
@@ -4,6 +4,38 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {autocompletion} from '@codemirror/autocomplete';
8
+ import {defaultKeymap, history, historyKeymap, indentWithTab} from '@codemirror/commands';
9
+ import {
10
+ defaultHighlightStyle,
11
+ foldGutter,
12
+ foldKeymap,
13
+ indentOnInput,
14
+ LanguageSupport,
15
+ syntaxHighlighting
16
+ } from '@codemirror/language';
17
+ import {linter, lintGutter} from '@codemirror/lint';
18
+ import {highlightSelectionMatches, search} from '@codemirror/search';
19
+ import {
20
+ Compartment,
21
+ EditorState,
22
+ Extension,
23
+ RangeSetBuilder,
24
+ StateEffect,
25
+ StateField
26
+ } from '@codemirror/state';
27
+ import {
28
+ Decoration,
29
+ DecorationSet,
30
+ EditorView,
31
+ highlightActiveLine,
32
+ highlightActiveLineGutter,
33
+ keymap,
34
+ lineNumbers,
35
+ ViewPlugin,
36
+ ViewUpdate
37
+ } from '@codemirror/view';
38
+ import {oneDark} from './impl/one-dark';
7
39
  import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
8
40
  import {box, div, filler, fragment, frame, hbox, label, span, vbox} from '@xh/hoist/cmp/layout';
9
41
  import {hoistCmp, HoistProps, LayoutProps, managed, PlainObject, XH} from '@xh/hoist/core';
@@ -13,32 +45,19 @@ import {textInput} from '@xh/hoist/desktop/cmp/input/TextInput';
13
45
  import {modalSupport} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupport';
14
46
  import {ModalSupportModel} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupportModel';
15
47
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
16
- import '@xh/hoist/desktop/register';
17
48
  import {Icon} from '@xh/hoist/icon';
18
- import {textArea} from '@xh/hoist/kit/blueprint';
19
49
  import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx';
20
- import {wait} from '@xh/hoist/promise';
21
50
  import {withDefault} from '@xh/hoist/utils/js';
22
51
  import {getLayoutProps} from '@xh/hoist/utils/react';
23
52
  import classNames from 'classnames';
24
- import * as codemirror from 'codemirror';
25
- import 'codemirror/addon/fold/brace-fold.js';
26
- import 'codemirror/addon/fold/foldcode.js';
27
- import 'codemirror/addon/fold/foldgutter.css';
28
- import 'codemirror/addon/fold/foldgutter.js';
29
- import 'codemirror/addon/lint/lint.css';
30
- import 'codemirror/addon/lint/lint.js';
31
- import 'codemirror/addon/scroll/simplescrollbars.css';
32
- import 'codemirror/addon/scroll/simplescrollbars.js';
33
- import 'codemirror/addon/search/searchcursor.js';
34
- import 'codemirror/addon/selection/mark-selection.js';
35
- import 'codemirror/lib/codemirror.css';
36
- import 'codemirror/theme/dracula.css';
37
- import {compact, defaultsDeep, isEqual, isFunction} from 'lodash';
53
+ import {compact, isEmpty, isFunction, isObject} from 'lodash';
38
54
  import {ReactElement} from 'react';
39
- import {findDOMNode} from 'react-dom';
40
55
  import './CodeInput.scss';
41
-
56
+ import {javascript} from '@codemirror/lang-javascript';
57
+ import {json} from '@codemirror/lang-json';
58
+ import {html} from '@codemirror/lang-html';
59
+ import {sql} from '@codemirror/lang-sql';
60
+ import {css} from '@codemirror/lang-css';
42
61
  export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps {
43
62
  /** True to focus the control on render. */
44
63
  autoFocus?: boolean;
@@ -46,12 +65,6 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
46
65
  /** False to not commit on every change/keystroke, default true. */
47
66
  commitOnChange?: boolean;
48
67
 
49
- /**
50
- * Configuration object with any properties supported by the CodeMirror API.
51
- * @see {@link https://codemirror.net/doc/manual.html#api_configuration|CodeMirror Docs}
52
- */
53
- editorProps?: PlainObject;
54
-
55
68
  /**
56
69
  * True to enable case-insensitive searching within the input. Default false, except in
57
70
  * fullscreen mode, where search will be shown unless explicitly *disabled*. Note that
@@ -72,10 +85,10 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
72
85
 
73
86
  /**
74
87
  * A CodeMirror language mode - default none (plain-text). See the CodeMirror docs
75
- * ({@link https://codemirror.net/mode/}) regarding available modes.
76
- * Applications must import any mode they wish to enable.
88
+ * ({@link https://github.com/codemirror/language-data/blob/main/src/language-data.ts}) regarding available languages.
89
+ * String can be the alias or name
77
90
  */
78
- mode?: string;
91
+ language?: string;
79
92
 
80
93
  /**
81
94
  * True to prevent user modification of editor contents, while still allowing user to
@@ -101,6 +114,15 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
101
114
  * action buttons show only when the input focused and float in the bottom-right corner.
102
115
  */
103
116
  showToolbar?: boolean;
117
+
118
+ /** False (default) to highlight active line in input. */
119
+ highlightActiveLine?: boolean;
120
+
121
+ /** True (default) to add line numbers to gutter. */
122
+ lineNumbers?: boolean | PlainObject;
123
+
124
+ /** False (default) to add line numbers to gutter. */
125
+ lineWrapping?: boolean | PlainObject;
104
126
  }
105
127
 
106
128
  /**
@@ -131,17 +153,17 @@ class CodeInputModel extends HoistInputModel {
131
153
  @managed
132
154
  modalSupportModel: ModalSupportModel = new ModalSupportModel();
133
155
 
134
- /** A CodeMirror editor instance. */
135
- editor: any;
156
+ editor: EditorView;
136
157
 
137
158
  // Support for internal search feature.
138
159
  cursor = null;
139
160
  @bindable query: string = '';
140
161
  @observable currentMatchIdx: number = -1;
141
- @observable.ref matches = [];
142
- get matchCount(): number {
143
- return this.matches.length;
144
- }
162
+ @observable.ref matches: {from: number; to: number}[] = [];
163
+
164
+ private updateMatchesEffect = StateEffect.define<void>();
165
+ private highlightField: StateField<DecorationSet>;
166
+ private themeCompartment = new Compartment();
145
167
 
146
168
  get fullScreen(): boolean {
147
169
  return this.modalSupportModel.isModal;
@@ -214,8 +236,7 @@ class CodeInputModel extends HoistInputModel {
214
236
  }
215
237
 
216
238
  override blur() {
217
- this.editor?.execCommand('undoSelection');
218
- this.editor?.getInputField().blur();
239
+ this.editor?.contentDOM.blur();
219
240
  }
220
241
 
221
242
  override focus() {
@@ -223,12 +244,34 @@ class CodeInputModel extends HoistInputModel {
223
244
  }
224
245
 
225
246
  override select() {
226
- this.editor?.execCommand('selectAll');
247
+ if (!this.editor) return;
248
+ this.editor.dispatch({selection: {anchor: 0, head: this.editor.state.doc.length}});
227
249
  }
228
250
 
229
251
  constructor() {
230
252
  super();
231
253
  makeObservable(this);
254
+
255
+ this.highlightField = StateField.define<DecorationSet>({
256
+ create: () => Decoration.none,
257
+ update: (deco, tr) => {
258
+ deco = deco.map(tr.changes);
259
+ if (tr.effects.some(e => e.is(this.updateMatchesEffect))) {
260
+ const builder = new RangeSetBuilder<Decoration>();
261
+ this.matches.forEach(match => {
262
+ builder.add(
263
+ match.from,
264
+ match.to,
265
+ Decoration.mark({class: 'xh-code-input--highlight'})
266
+ );
267
+ });
268
+ deco = builder.finish();
269
+ }
270
+ return deco;
271
+ },
272
+ provide: f => EditorView.decorations.from(f)
273
+ });
274
+
232
275
  this.addReaction({
233
276
  track: () => this.modalSupportModel.isModal,
234
277
  run: () => this.focus(),
@@ -240,103 +283,61 @@ class CodeInputModel extends HoistInputModel {
240
283
  this.addReaction({
241
284
  track: () => XH.darkTheme,
242
285
  run: () => {
243
- const {editor} = this;
244
- if (editor) editor.setOption('theme', XH.darkTheme ? 'dracula' : 'default');
286
+ if (!this.editor) return;
287
+ this.editor.dispatch({
288
+ effects: this.themeCompartment.reconfigure(this.getThemeExtension())
289
+ });
245
290
  }
246
291
  });
247
292
 
248
293
  this.addReaction({
249
294
  track: () => this.renderValue,
250
- run: value => {
251
- const {editor} = this;
252
- if (editor && editor.getValue() != value) {
253
- // CodeMirror will throw on null value.
254
- editor.setValue(value == null ? '' : value);
295
+ run: val => {
296
+ if (this.editor && this.editor.state.doc.toString() !== val) {
297
+ this.editor.dispatch({
298
+ changes: {from: 0, to: this.editor.state.doc.length, insert: val ?? ''}
299
+ });
255
300
  }
256
301
  }
257
302
  });
258
303
 
259
304
  this.addReaction({
260
305
  track: () => this.componentProps.readonly || this.componentProps.disabled,
261
- run: editorReadOnly => {
262
- this.editor.setOption('readOnly', editorReadOnly);
306
+ run: readOnly => {
307
+ if (this.editor)
308
+ this.editor.dispatch({
309
+ effects: StateEffect.appendConfig.of(EditorView.editable.of(!readOnly))
310
+ });
263
311
  }
264
312
  });
265
313
 
266
314
  this.addReaction({
267
315
  track: () => this.query,
268
316
  run: query => {
269
- if (query?.trim()) {
270
- this.findAll();
271
- } else {
272
- this.clearSearchResults();
273
- }
317
+ if (query?.trim()) this.findAll();
318
+ else this.clearSearchResults();
274
319
  },
275
320
  debounce: 300
276
321
  });
277
322
  }
278
323
 
279
- manageCodeEditor = textAreaComp => {
280
- if (textAreaComp) {
281
- this.editor = this.createCodeEditor(textAreaComp);
282
- this.preserveSearchResults();
283
- }
284
- };
285
-
286
- createCodeEditor(textAreaComp) {
287
- const editorSpec = defaultsDeep(this.componentProps.editorProps, this.createDefaults());
288
-
289
- const taDom = findDOMNode(textAreaComp),
290
- editor = codemirror.fromTextArea(taDom, editorSpec);
291
-
292
- editor.on('change', this.handleEditorChange);
293
- return editor;
294
- }
295
-
296
- createDefaults() {
297
- const {disabled, readonly, mode, linter, autoFocus} = this.componentProps;
298
- let gutters = ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'];
299
- if (linter) gutters.push('CodeMirror-lint-markers');
300
-
301
- return {
302
- mode,
303
- theme: XH.darkTheme ? 'dracula' : 'default',
304
- lineWrapping: false,
305
- lineNumbers: true,
306
- autoCloseBrackets: true,
307
- extraKeys: {
308
- 'Cmd-P': this.onAutoFormat,
309
- 'Ctrl-P': this.onAutoFormat
310
- },
311
- foldGutter: true,
312
- scrollbarStyle: 'simple',
313
- readOnly: disabled || readonly,
314
- gutters,
315
- lint: linter ? {getAnnotations: linter} : false,
316
- autoFocus
317
- };
318
- }
319
-
320
- onChange = ev => {
321
- this.noteValueChange(ev.target.value);
322
- };
324
+ createCodeEditor = async (container: HTMLElement) => {
325
+ if (!container) return;
326
+ const extensions = await this.getExtensionsAsync();
323
327
 
324
- handleEditorChange = editor => {
325
- this.noteValueChange(editor.getValue());
326
- if (this.cursor) this.clearSearchResults();
328
+ const state = EditorState.create({doc: this.renderValue || '', extensions});
329
+ this.editor = new EditorView({state, parent: container});
327
330
  };
328
331
 
329
- onAutoFormat = () => {
330
- if (!isFunction(this.componentProps.formatter)) return;
331
-
332
- const editor = this.editor,
333
- val = this.tryPrettyPrint(editor.getValue());
334
- editor.setValue(val);
335
- };
332
+ onAutoFormat() {
333
+ if (!this.editor) return;
334
+ const val = this.tryPrettyPrint(this.editor.state.doc.toString());
335
+ this.editor.dispatch({changes: {from: 0, to: this.editor.state.doc.length, insert: val}});
336
+ }
336
337
 
337
- tryPrettyPrint(str) {
338
+ tryPrettyPrint(str: string) {
338
339
  try {
339
- return this.componentProps.formatter(str);
340
+ return this.componentProps.formatter?.(str) ?? str;
340
341
  } catch (e) {
341
342
  return str;
342
343
  }
@@ -344,106 +345,180 @@ class CodeInputModel extends HoistInputModel {
344
345
 
345
346
  toggleFullScreen() {
346
347
  this.modalSupportModel.toggleIsModal();
347
-
348
- // 'Nudge' the mouse wheel to trigger CodeMirror to update scrollbar state
349
- const scrollEvent = d => new window.WheelEvent('mousewheel', {deltaX: d, deltaY: d});
350
- wait().then(() => {
351
- this.editor.getScrollerElement().dispatchEvent(scrollEvent(2));
352
- this.editor.getScrollerElement().dispatchEvent(scrollEvent(-2));
353
- });
354
348
  }
355
349
 
356
- //------------------------
357
- // Local Searching
358
- //------------------------
359
350
  @action
360
351
  findAll() {
361
- this.clearSearchResults();
362
- if (!this.query?.trim()) return;
363
-
364
- this.cursor = this.editor.getSearchCursor(this.query, 0, true);
365
-
366
- const {cursor, editor} = this,
367
- newMatches = [];
368
-
369
- while (cursor.findNext()) {
370
- const anchor = cursor.from(),
371
- head = cursor.to();
372
- newMatches.push({
373
- anchor,
374
- head,
375
- textMarker: editor.markText(anchor, head, {className: 'xh-code-input--highlight'})
376
- });
352
+ if (!this.editor || !this.query?.trim()) return;
353
+
354
+ let doc = this.editor.state.doc.toString(),
355
+ matches = [],
356
+ idx = doc.indexOf(this.query);
357
+ while (idx !== -1) {
358
+ matches.push({from: idx, to: idx + this.query.length});
359
+ idx = doc.indexOf(this.query, idx + 1);
377
360
  }
378
-
379
- this.matches = newMatches;
380
- if (newMatches.length) {
381
- this.findNext();
382
- } else {
383
- this.currentMatchIdx = -1;
361
+ this.matches = matches;
362
+ this.currentMatchIdx = matches.length ? 0 : -1;
363
+ this.updateMatchDecorations();
364
+
365
+ if (matches.length) {
366
+ const match = matches[0];
367
+ this.editor.dispatch({
368
+ selection: {anchor: match.from, head: match.to},
369
+ scrollIntoView: true
370
+ });
384
371
  }
385
372
  }
386
373
 
387
374
  @action
388
375
  findNext() {
389
- const {editor, query, cursor, matchCount} = this;
390
- if (!cursor || !matchCount) return;
391
-
392
- if (cursor.findNext(query)) {
393
- this.handleCursorMatchUpdate();
394
- } else {
395
- // Loop around
396
- this.cursor = editor.getSearchCursor(query, 0, true);
397
- this.findNext();
398
- }
376
+ if (!this.editor || !this.matches.length) return;
377
+ this.currentMatchIdx = (this.currentMatchIdx + 1) % this.matches.length;
378
+ const match = this.matches[this.currentMatchIdx];
379
+ this.editor.dispatch({
380
+ selection: {anchor: match.from, head: match.to},
381
+ scrollIntoView: true
382
+ });
399
383
  }
400
384
 
401
385
  @action
402
386
  findPrevious() {
403
- const {editor, query, cursor, matches, matchCount} = this;
404
- if (!cursor || !matchCount) return;
405
-
406
- if (cursor.findPrevious(query)) {
407
- this.handleCursorMatchUpdate();
408
- } else {
409
- // Loop around
410
- this.cursor = editor.getSearchCursor(query, matches[matchCount - 1].head, true);
411
- this.findPrevious();
412
- }
387
+ if (!this.editor || !this.matches.length) return;
388
+ this.currentMatchIdx =
389
+ (this.currentMatchIdx - 1 + this.matches.length) % this.matches.length;
390
+ const match = this.matches[this.currentMatchIdx];
391
+ this.editor.dispatch({
392
+ selection: {anchor: match.from, head: match.to},
393
+ scrollIntoView: true
394
+ });
413
395
  }
414
396
 
415
397
  @action
416
- handleCursorMatchUpdate() {
417
- const {editor, cursor, matches} = this,
418
- from = cursor.from(),
419
- to = cursor.to();
420
- editor.scrollIntoView({from, to}, 50);
421
- editor.setSelection(from, to);
422
- this.currentMatchIdx = matches.findIndex(match => isEqual(match.anchor, from));
423
- }
424
-
425
- preserveSearchResults() {
426
- const {matches, editor} = this;
427
- matches.forEach(match => {
428
- match.textMarker = editor.markText(match.anchor, match.head, {
429
- className: 'xh-code-input--highlight'
430
- });
431
- });
398
+ updateMatchDecorations() {
399
+ if (!this.editor) return;
400
+ this.editor.dispatch({effects: this.updateMatchesEffect.of()});
432
401
  }
433
402
 
434
403
  @action
435
404
  clearSearchResults() {
436
- this.cursor = null;
437
- this.currentMatchIdx = -1;
438
- this.matches.forEach(match => match.textMarker.clear());
439
405
  this.matches = [];
406
+ this.currentMatchIdx = -1;
407
+ this.updateMatchDecorations();
440
408
  }
441
409
 
442
410
  override destroy() {
443
- // Cleanup editor component as per CodeMirror docs.
444
- if (this.editor) this.editor.toTextArea();
411
+ this.editor?.destroy();
445
412
  super.destroy();
446
413
  }
414
+
415
+ //------------------------
416
+ // Implementation
417
+ //------------------------
418
+ private async getExtensionsAsync(): Promise<Extension[]> {
419
+ const {
420
+ autoFocus,
421
+ language,
422
+ readonly,
423
+ highlightActiveLine: propsHighlightActiveLine,
424
+ linter: propsLinter,
425
+ lineNumbers: propsLineNumbers = true,
426
+ lineWrapping: propsLineWrapping = false
427
+ } = this.componentProps,
428
+ extensions = [
429
+ // Theme
430
+ this.themeCompartment.of(this.getThemeExtension()),
431
+ // Editor state
432
+ EditorView.editable.of(!readonly),
433
+ EditorView.updateListener.of((update: ViewUpdate) => {
434
+ if (update.docChanged) this.noteValueChange(update.state.doc.toString());
435
+ }),
436
+ // Search & custom highlight
437
+ search(),
438
+ syntaxHighlighting(defaultHighlightStyle),
439
+ highlightSelectionMatches(),
440
+ this.highlightField,
441
+ // Editor UI
442
+ foldGutter(),
443
+ lintGutter(),
444
+ indentOnInput(),
445
+ autocompletion(),
446
+ history(),
447
+ // Linter
448
+ propsLinter
449
+ ? linter(async view => {
450
+ const text = view.state.doc.toString();
451
+ return await propsLinter(text);
452
+ })
453
+ : [],
454
+ // Key bindings
455
+ keymap.of([
456
+ ...defaultKeymap,
457
+ ...historyKeymap,
458
+ ...foldKeymap,
459
+ indentWithTab,
460
+ {
461
+ key: 'Mod-p',
462
+ run: () => {
463
+ this.onAutoFormat();
464
+ return true;
465
+ }
466
+ }
467
+ ])
468
+ ];
469
+
470
+ if (propsLineWrapping) extensions.push(EditorView.lineWrapping);
471
+ if (propsLineNumbers) {
472
+ isObject(propsLineNumbers)
473
+ ? extensions.push(lineNumbers(propsLineNumbers))
474
+ : extensions.push(lineNumbers());
475
+ }
476
+ if (propsHighlightActiveLine)
477
+ extensions.push(highlightActiveLine(), highlightActiveLineGutter());
478
+ if (autoFocus) extensions.push(this.autofocusExtension);
479
+ if (language) extensions.push(await this.getLanguageExtensionAsync(language));
480
+
481
+ return extensions.filter(it => !isEmpty(it));
482
+ }
483
+
484
+ private getThemeExtension() {
485
+ const lightTheme = EditorView.theme({}, {dark: false});
486
+ return XH.darkTheme ? oneDark : lightTheme;
487
+ }
488
+
489
+ private LANGUAGE_EXTENSIONS: Record<string, () => LanguageSupport> = {
490
+ js: javascript,
491
+ javascript: javascript,
492
+ html: html,
493
+ css: css,
494
+ json: json,
495
+ sql: sql
496
+ };
497
+
498
+ private async getLanguageExtensionAsync(lang: string): Promise<LanguageSupport> {
499
+ if (!lang) return null;
500
+ const extFactory = this.LANGUAGE_EXTENSIONS[lang.toLowerCase()];
501
+ if (!extFactory) {
502
+ console.warn(`Language not found: ${lang}`);
503
+ return null;
504
+ }
505
+ try {
506
+ // DEBUG
507
+ console.log(extFactory());
508
+ return extFactory(); // returns a LanguageSupport instance
509
+ } catch (err) {
510
+ console.error(`Failed to load language: ${lang}`, err);
511
+ return null;
512
+ }
513
+ }
514
+
515
+ private autofocusExtension = ViewPlugin.fromClass(
516
+ class {
517
+ constructor(view: EditorView) {
518
+ queueMicrotask(() => view.focus());
519
+ }
520
+ }
521
+ );
447
522
  }
448
523
 
449
524
  const cmp = hoistCmp.factory<CodeInputModel>(({model, className, ...props}, ref) => {
@@ -471,11 +546,7 @@ const inputCmp = hoistCmp.factory<CodeInputModel>(({model, ...props}, ref) =>
471
546
  items: [
472
547
  div({
473
548
  className: 'xh-code-input__inner-wrapper',
474
- item: textArea({
475
- value: model.renderValue || '',
476
- inputRef: model.manageCodeEditor,
477
- onChange: model.onChange
478
- })
549
+ ref: model.createCodeEditor
479
550
  }),
480
551
  model.showToolbar ? toolbarCmp() : actionButtonsCmp()
481
552
  ],
@@ -496,7 +567,9 @@ const toolbarCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
496
567
  });
497
568
 
498
569
  const searchInputCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
499
- const {query, cursor, currentMatchIdx, matchCount, fullScreen} = model;
570
+ const {query, currentMatchIdx, matches, fullScreen} = model,
571
+ matchCount = matches.length;
572
+
500
573
  return fragment(
501
574
  // Frame wrapper added due to issues with textInput not supporting all layout props as it should.
502
575
  frame({
@@ -505,20 +578,16 @@ const searchInputCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
505
578
  item: textInput({
506
579
  width: null,
507
580
  flex: 1,
508
- model: this,
581
+ model,
509
582
  bind: 'query',
510
583
  leftIcon: Icon.search(),
511
584
  enableClear: true,
512
585
  commitOnChange: true,
513
586
  onKeyDown: e => {
514
587
  if (e.key !== 'Enter') return;
515
- if (!cursor) {
516
- model.findAll();
517
- } else if (e.shiftKey) {
518
- model.findPrevious();
519
- } else {
520
- model.findNext();
521
- }
588
+ if (!matches.length) model.findAll();
589
+ else if (e.shiftKey) model.findPrevious();
590
+ else model.findNext();
522
591
  }
523
592
  })
524
593
  }),