@xh/hoist 79.0.0-SNAPSHOT.1765576473366 → 79.0.0-SNAPSHOT.1765576704346

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 {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 {dracula, solarizedLight} from '@uiw/codemirror-themes-all';
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 {languages} from '@codemirror/language-data';
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;
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
+ };
334
318
  }
335
319
 
336
- tryPrettyPrint(str: string) {
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,166 +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
- 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
- private getThemeExtension() {
482
- return XH.darkTheme ? dracula : solarizedLight;
483
- }
484
-
485
- private async getLanguageExtensionAsync(lang: string): Promise<LanguageSupport> {
486
- try {
487
- const langDesc: LanguageDescription | undefined = find(
488
- languages,
489
- it => includes(it.alias, lang) || it.name.toLowerCase() === lang.toLowerCase()
490
- );
491
- if (!langDesc) return null;
492
- return await langDesc.load();
493
- } catch (err) {
494
- console.error(`Failed to load language: ${lang}`, err);
495
- return null;
496
- }
497
- }
498
-
499
- private autofocusExtension = ViewPlugin.fromClass(
500
- class {
501
- constructor(view: EditorView) {
502
- queueMicrotask(() => view.focus());
503
- }
504
- }
505
- );
506
447
  }
507
448
 
508
449
  const cmp = hoistCmp.factory<CodeInputModel>(({model, className, ...props}, ref) => {
@@ -530,7 +471,11 @@ const inputCmp = hoistCmp.factory<CodeInputModel>(({model, ...props}, ref) =>
530
471
  items: [
531
472
  div({
532
473
  className: 'xh-code-input__inner-wrapper',
533
- ref: model.createCodeEditor
474
+ item: textArea({
475
+ value: model.renderValue || '',
476
+ inputRef: model.manageCodeEditor,
477
+ onChange: model.onChange
478
+ })
534
479
  }),
535
480
  model.showToolbar ? toolbarCmp() : actionButtonsCmp()
536
481
  ],
@@ -551,9 +496,7 @@ const toolbarCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
551
496
  });
552
497
 
553
498
  const searchInputCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
554
- const {query, currentMatchIdx, matches, fullScreen} = model,
555
- matchCount = matches.length;
556
-
499
+ const {query, cursor, currentMatchIdx, matchCount, fullScreen} = model;
557
500
  return fragment(
558
501
  // Frame wrapper added due to issues with textInput not supporting all layout props as it should.
559
502
  frame({
@@ -562,16 +505,20 @@ const searchInputCmp = hoistCmp.factory<CodeInputModel>(({model}) => {
562
505
  item: textInput({
563
506
  width: null,
564
507
  flex: 1,
565
- model,
508
+ model: this,
566
509
  bind: 'query',
567
510
  leftIcon: Icon.search(),
568
511
  enableClear: true,
569
512
  commitOnChange: true,
570
513
  onKeyDown: e => {
571
514
  if (e.key !== 'Enter') return;
572
- if (!matches.length) model.findAll();
573
- else if (e.shiftKey) model.findPrevious();
574
- else model.findNext();
515
+ if (!cursor) {
516
+ model.findAll();
517
+ } else if (e.shiftKey) {
518
+ model.findPrevious();
519
+ } else {
520
+ model.findNext();
521
+ }
575
522
  }
576
523
  })
577
524
  }),