@xh/hoist 79.0.0-SNAPSHOT.1765835240904 → 79.0.0-SNAPSHOT.1765845531181

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