@xh/hoist 79.0.0-SNAPSHOT.1765828263630 → 79.0.0-SNAPSHOT.1765828486265

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