@xh/hoist 79.0.0-SNAPSHOT.1765819048471 → 79.0.0-SNAPSHOT.1765824728801

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,39 @@
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 {linter, lintGutter} from '@codemirror/lint';
19
+ import {highlightSelectionMatches, search} from '@codemirror/search';
20
+ import {
21
+ Compartment,
22
+ EditorState,
23
+ Extension,
24
+ RangeSetBuilder,
25
+ StateEffect,
26
+ StateField
27
+ } from '@codemirror/state';
28
+ import {
29
+ Decoration,
30
+ DecorationSet,
31
+ EditorView,
32
+ highlightActiveLine,
33
+ highlightActiveLineGutter,
34
+ keymap,
35
+ lineNumbers,
36
+ ViewPlugin,
37
+ ViewUpdate
38
+ } from '@codemirror/view';
39
+ import {oneDark} from './impl/one-dark';
7
40
  import {HoistInputModel, HoistInputProps, useHoistInputModel} from '@xh/hoist/cmp/input';
8
41
  import {box, div, filler, fragment, frame, hbox, label, span, vbox} from '@xh/hoist/cmp/layout';
9
42
  import {hoistCmp, HoistProps, LayoutProps, managed, PlainObject, XH} from '@xh/hoist/core';
@@ -13,31 +46,15 @@ import {textInput} from '@xh/hoist/desktop/cmp/input/TextInput';
13
46
  import {modalSupport} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupport';
14
47
  import {ModalSupportModel} from '@xh/hoist/desktop/cmp/modalsupport/ModalSupportModel';
15
48
  import {toolbar} from '@xh/hoist/desktop/cmp/toolbar';
16
- import '@xh/hoist/desktop/register';
17
49
  import {Icon} from '@xh/hoist/icon';
18
- import {textArea} from '@xh/hoist/kit/blueprint';
19
50
  import {action, bindable, makeObservable, observable} from '@xh/hoist/mobx';
20
- import {wait} from '@xh/hoist/promise';
21
51
  import {withDefault} from '@xh/hoist/utils/js';
22
52
  import {getLayoutProps} from '@xh/hoist/utils/react';
23
53
  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';
54
+ import {compact, find, includes, isEmpty, isFunction, isObject} from 'lodash';
38
55
  import {ReactElement} from 'react';
39
- import {findDOMNode} from 'react-dom';
40
56
  import './CodeInput.scss';
57
+ import {languages} from '@codemirror/language-data';
41
58
 
42
59
  export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps {
43
60
  /** True to focus the control on render. */
@@ -46,12 +63,6 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
46
63
  /** False to not commit on every change/keystroke, default true. */
47
64
  commitOnChange?: boolean;
48
65
 
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
66
  /**
56
67
  * True to enable case-insensitive searching within the input. Default false, except in
57
68
  * fullscreen mode, where search will be shown unless explicitly *disabled*. Note that
@@ -72,10 +83,10 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
72
83
 
73
84
  /**
74
85
  * 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.
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
77
88
  */
78
- mode?: string;
89
+ language?: string;
79
90
 
80
91
  /**
81
92
  * True to prevent user modification of editor contents, while still allowing user to
@@ -101,6 +112,15 @@ export interface CodeInputProps extends HoistProps, HoistInputProps, LayoutProps
101
112
  * action buttons show only when the input focused and float in the bottom-right corner.
102
113
  */
103
114
  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;
104
124
  }
105
125
 
106
126
  /**
@@ -131,17 +151,17 @@ class CodeInputModel extends HoistInputModel {
131
151
  @managed
132
152
  modalSupportModel: ModalSupportModel = new ModalSupportModel();
133
153
 
134
- /** A CodeMirror editor instance. */
135
- editor: any;
154
+ editor: EditorView;
136
155
 
137
156
  // Support for internal search feature.
138
157
  cursor = null;
139
158
  @bindable query: string = '';
140
159
  @observable currentMatchIdx: number = -1;
141
- @observable.ref matches = [];
142
- get matchCount(): number {
143
- return this.matches.length;
144
- }
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();
145
165
 
146
166
  get fullScreen(): boolean {
147
167
  return this.modalSupportModel.isModal;
@@ -214,8 +234,7 @@ class CodeInputModel extends HoistInputModel {
214
234
  }
215
235
 
216
236
  override blur() {
217
- this.editor?.execCommand('undoSelection');
218
- this.editor?.getInputField().blur();
237
+ this.editor?.contentDOM.blur();
219
238
  }
220
239
 
221
240
  override focus() {
@@ -223,12 +242,34 @@ class CodeInputModel extends HoistInputModel {
223
242
  }
224
243
 
225
244
  override select() {
226
- this.editor?.execCommand('selectAll');
245
+ if (!this.editor) return;
246
+ this.editor.dispatch({selection: {anchor: 0, head: this.editor.state.doc.length}});
227
247
  }
228
248
 
229
249
  constructor() {
230
250
  super();
231
251
  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
+
232
273
  this.addReaction({
233
274
  track: () => this.modalSupportModel.isModal,
234
275
  run: () => this.focus(),
@@ -240,103 +281,61 @@ class CodeInputModel extends HoistInputModel {
240
281
  this.addReaction({
241
282
  track: () => XH.darkTheme,
242
283
  run: () => {
243
- const {editor} = this;
244
- if (editor) editor.setOption('theme', XH.darkTheme ? 'dracula' : 'default');
284
+ if (!this.editor) return;
285
+ this.editor.dispatch({
286
+ effects: this.themeCompartment.reconfigure(this.getThemeExtension())
287
+ });
245
288
  }
246
289
  });
247
290
 
248
291
  this.addReaction({
249
292
  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);
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
+ });
255
298
  }
256
299
  }
257
300
  });
258
301
 
259
302
  this.addReaction({
260
303
  track: () => this.componentProps.readonly || this.componentProps.disabled,
261
- run: editorReadOnly => {
262
- this.editor.setOption('readOnly', editorReadOnly);
304
+ run: readOnly => {
305
+ if (this.editor)
306
+ this.editor.dispatch({
307
+ effects: StateEffect.appendConfig.of(EditorView.editable.of(!readOnly))
308
+ });
263
309
  }
264
310
  });
265
311
 
266
312
  this.addReaction({
267
313
  track: () => this.query,
268
314
  run: query => {
269
- if (query?.trim()) {
270
- this.findAll();
271
- } else {
272
- this.clearSearchResults();
273
- }
315
+ if (query?.trim()) this.findAll();
316
+ else this.clearSearchResults();
274
317
  },
275
318
  debounce: 300
276
319
  });
277
320
  }
278
321
 
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
- };
322
+ createCodeEditor = async (container: HTMLElement) => {
323
+ if (!container) return;
324
+ const extensions = await this.getExtensionsAsync();
323
325
 
324
- handleEditorChange = editor => {
325
- this.noteValueChange(editor.getValue());
326
- if (this.cursor) this.clearSearchResults();
326
+ const state = EditorState.create({doc: this.renderValue || '', extensions});
327
+ this.editor = new EditorView({state, parent: container});
327
328
  };
328
329
 
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
- };
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}});
334
+ }
336
335
 
337
- tryPrettyPrint(str) {
336
+ tryPrettyPrint(str: string) {
338
337
  try {
339
- return this.componentProps.formatter(str);
338
+ return this.componentProps.formatter?.(str) ?? str;
340
339
  } catch (e) {
341
340
  return str;
342
341
  }
@@ -344,106 +343,168 @@ class CodeInputModel extends HoistInputModel {
344
343
 
345
344
  toggleFullScreen() {
346
345
  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
346
  }
355
347
 
356
- //------------------------
357
- // Local Searching
358
- //------------------------
359
348
  @action
360
349
  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
- });
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);
377
358
  }
378
-
379
- this.matches = newMatches;
380
- if (newMatches.length) {
381
- this.findNext();
382
- } else {
383
- this.currentMatchIdx = -1;
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
368
+ });
384
369
  }
385
370
  }
386
371
 
387
372
  @action
388
373
  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
- }
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
+ });
399
381
  }
400
382
 
401
383
  @action
402
384
  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
- }
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
+ });
413
393
  }
414
394
 
415
395
  @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
- });
396
+ updateMatchDecorations() {
397
+ if (!this.editor) return;
398
+ this.editor.dispatch({effects: this.updateMatchesEffect.of()});
432
399
  }
433
400
 
434
401
  @action
435
402
  clearSearchResults() {
436
- this.cursor = null;
437
- this.currentMatchIdx = -1;
438
- this.matches.forEach(match => match.textMarker.clear());
439
403
  this.matches = [];
404
+ this.currentMatchIdx = -1;
405
+ this.updateMatchDecorations();
440
406
  }
441
407
 
442
408
  override destroy() {
443
- // Cleanup editor component as per CodeMirror docs.
444
- if (this.editor) this.editor.toTextArea();
409
+ this.editor?.destroy();
445
410
  super.destroy();
446
411
  }
412
+
413
+ //------------------------
414
+ // Implementation
415
+ //------------------------
416
+ private async getExtensionsAsync(): Promise<Extension[]> {
417
+ const {
418
+ autoFocus,
419
+ language,
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
+ // 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) extensions.push(await this.getLanguageExtensionAsync(language));
478
+
479
+ return extensions.filter(it => !isEmpty(it));
480
+ }
481
+
482
+ private getThemeExtension() {
483
+ const lightTheme = EditorView.theme({}, {dark: false});
484
+ return XH.darkTheme ? oneDark : lightTheme;
485
+ }
486
+
487
+ private async getLanguageExtensionAsync(lang: string): Promise<LanguageSupport> {
488
+ try {
489
+ const langDesc: LanguageDescription | undefined = find(
490
+ languages,
491
+ it => includes(it.alias, lang) || it.name.toLowerCase() === lang.toLowerCase()
492
+ );
493
+ if (!langDesc) return null;
494
+ return await langDesc.load();
495
+ } catch (err) {
496
+ console.error(`Failed to load language: ${lang}`, err);
497
+ return null;
498
+ }
499
+ }
500
+
501
+ private autofocusExtension = ViewPlugin.fromClass(
502
+ class {
503
+ constructor(view: EditorView) {
504
+ queueMicrotask(() => view.focus());
505
+ }
506
+ }
507
+ );
447
508
  }
448
509
 
449
510
  const cmp = hoistCmp.factory<CodeInputModel>(({model, className, ...props}, ref) => {
@@ -471,11 +532,7 @@ const inputCmp = hoistCmp.factory<CodeInputModel>(({model, ...props}, ref) =>
471
532
  items: [
472
533
  div({
473
534
  className: 'xh-code-input__inner-wrapper',
474
- item: textArea({
475
- value: model.renderValue || '',
476
- inputRef: model.manageCodeEditor,
477
- onChange: model.onChange
478
- })
535
+ ref: model.createCodeEditor
479
536
  }),
480
537
  model.showToolbar ? toolbarCmp() : actionButtonsCmp()
481
538
  ],
@@ -496,7 +553,9 @@ const toolbarCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
496
553
  });
497
554
 
498
555
  const searchInputCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
499
- const {query, cursor, currentMatchIdx, matchCount, fullScreen} = model;
556
+ const {query, currentMatchIdx, matches, fullScreen} = model,
557
+ matchCount = matches.length;
558
+
500
559
  return fragment(
501
560
  // Frame wrapper added due to issues with textInput not supporting all layout props as it should.
502
561
  frame({
@@ -505,20 +564,16 @@ const searchInputCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
505
564
  item: textInput({
506
565
  width: null,
507
566
  flex: 1,
508
- model: this,
567
+ model,
509
568
  bind: 'query',
510
569
  leftIcon: Icon.search(),
511
570
  enableClear: true,
512
571
  commitOnChange: true,
513
572
  onKeyDown: e => {
514
573
  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
- }
574
+ if (!matches.length) model.findAll();
575
+ else if (e.shiftKey) model.findPrevious();
576
+ else model.findNext();
522
577
  }
523
578
  })
524
579
  }),