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

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