@xh/hoist 79.0.0-SNAPSHOT.1765824964847 → 79.0.0-SNAPSHOT.1765828263630

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