@xh/hoist 79.0.0-SNAPSHOT.1765846827962 → 79.0.0-SNAPSHOT.1765893892330

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