@xh/hoist 79.0.0-SNAPSHOT.1765854402334 → 79.0.0-SNAPSHOT.1766017582086

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