@trebco/treb 27.5.3 → 27.7.6

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.
@@ -0,0 +1,1276 @@
1
+ /*
2
+ * This file is part of TREB.
3
+ *
4
+ * TREB is free software: you can redistribute it and/or modify it under the
5
+ * terms of the GNU General Public License as published by the Free Software
6
+ * Foundation, either version 3 of the License, or (at your option) any
7
+ * later version.
8
+ *
9
+ * TREB is distributed in the hope that it will be useful, but WITHOUT ANY
10
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
+ * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
+ * details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License along
15
+ * with TREB. If not, see <https://www.gnu.org/licenses/>.
16
+ *
17
+ * Copyright 2022-2023 trebco, llc.
18
+ * info@treb.app
19
+ *
20
+ */
21
+
22
+ /**
23
+ * attempting (at least partial) rewrite of editor. better support of
24
+ * external editors, and a little cleaner behavior for context highlighting.
25
+ *
26
+ * I didn't want to handle spellcheck, but we're setting a flag reflecting
27
+ * whether it's a formula; so we probably should do it.
28
+ *
29
+ * we are specifically NOT handling the following:
30
+ *
31
+ * - enter key
32
+ *
33
+ * subclasses or callers can handle those.
34
+ *
35
+ */
36
+
37
+ import { Area, type IArea, type ICellAddress, IsCellAddress, Localization, type Theme, Rectangle, type Cell } from 'treb-base-types';
38
+ import type { ExpressionUnit, ParseResult, UnitAddress, UnitRange } from 'treb-parser';
39
+ import { Parser, QuotedSheetNameRegex } from 'treb-parser';
40
+ import type { DataModel, ViewModel } from '../types/data_model';
41
+ import type { Autocomplete, AutocompleteResult } from './autocomplete';
42
+ import { EventSource } from 'treb-utils';
43
+ import { type AutocompleteExecResult, AutocompleteMatcher, DescriptorType } from './autocomplete_matcher';
44
+
45
+ export interface UpdateTextOptions {
46
+ rewrite_addresses: boolean;
47
+ validate_addresses: boolean;
48
+ canonicalize_functions: boolean;
49
+ format_only: boolean;
50
+ toll_events: boolean;
51
+ }
52
+
53
+ type GenericEventListener = (event: Event) => any;
54
+
55
+ // ----------------
56
+
57
+ /*
58
+ export interface Editor2UpdateEvent {
59
+ type: 'update';
60
+ dependencies?: Area[];
61
+ }
62
+ */
63
+
64
+ /** event on commit, either enter or tab */
65
+ export interface FormulaEditorCommitEvent {
66
+ type: 'commit';
67
+
68
+ // selection?: GridSelection; // I think this is no longer used? can we drop?
69
+ value?: string;
70
+
71
+ /**
72
+ * true if commiting an array. note that if the cell _is_ an array,
73
+ * and you commit as !array, that should be an error.
74
+ */
75
+ array?: boolean;
76
+
77
+ /**
78
+ * for the formula editor, the event won't bubble so we can't handle
79
+ * it with the normal event handler -- so use the passed event to
80
+ */
81
+ event?: KeyboardEvent;
82
+ }
83
+
84
+ /** event on discard -- escape */
85
+ export interface FormulaEditorDiscardEvent {
86
+ type: 'discard';
87
+ }
88
+
89
+ /** event on end select state, reset selection */
90
+ export interface FormulaEditorEndSelectionEvent {
91
+ type: 'end-selection';
92
+ }
93
+
94
+ /** event on text update: need to update sheet dependencies */
95
+ export interface FormulaEditorUpdateEvent {
96
+ type: 'update';
97
+ text?: string;
98
+ cell?: Cell;
99
+ dependencies?: Area[];
100
+ }
101
+
102
+ // export interface FormulaEditorAutocompleteEvent {
103
+ // type: 'autocomplete';
104
+ // text?: string;
105
+ // cursor?: number;
106
+ // }
107
+
108
+ /*
109
+ export interface RetainFocusEvent {
110
+ type: 'retain-focus';
111
+ focus: boolean;
112
+ }
113
+ */
114
+
115
+ export interface StartEditingEvent {
116
+ type: 'start-editing';
117
+ editor?: string;
118
+ }
119
+
120
+ export interface StopEditingEvent {
121
+ type: 'stop-editing';
122
+ editor?: string;
123
+ }
124
+
125
+ /** discriminated union */
126
+ export type FormulaEditorEvent
127
+ = // RetainFocusEvent
128
+ | StopEditingEvent
129
+ | StartEditingEvent
130
+ | FormulaEditorUpdateEvent
131
+ | FormulaEditorCommitEvent
132
+ | FormulaEditorDiscardEvent
133
+ | FormulaEditorEndSelectionEvent
134
+ ;
135
+
136
+
137
+ // -----------------
138
+
139
+ export interface NodeDescriptor {
140
+
141
+ /** the contenteditable node */
142
+ node: HTMLElement;
143
+
144
+ /** list of references in this node */
145
+ references?: Area[];
146
+
147
+ /** listeners we attached, so we can clean up */
148
+ listeners?: Map<Partial<keyof HTMLElementEventMap>, GenericEventListener>;
149
+
150
+ /** last-known text, to avoid unecessary styling */
151
+ formatted_text?: string;
152
+
153
+ /** check (not sure if we still need this) length of html content */
154
+ check?: number;
155
+
156
+ }
157
+
158
+ export class Editor<E = FormulaEditorEvent> extends EventSource<E|FormulaEditorEvent> {
159
+
160
+ protected static readonly FormulaChars = ('$^&*(-+={[<>/~%' + Localization.argument_separator).split(''); // FIXME: i18n
161
+
162
+ /**
163
+ * the current edit cell. in the event we're editing a merged or
164
+ * array cell, this might be different than the actual target address.
165
+ */
166
+ public active_cell?: Cell;
167
+
168
+ /** matcher. passed in by owner. should move to constructor arguments */
169
+ public autocomplete_matcher?: AutocompleteMatcher;
170
+
171
+ /** the containing node, used for layout */
172
+ protected container_node?: HTMLElement;
173
+
174
+ /**
175
+ * this is the node we are currently editing. it's possible we are not
176
+ * editing any cell, but just formatting. this one sends events and is
177
+ * the target for inserting addresses.
178
+ */
179
+ protected active_editor?: NodeDescriptor;
180
+
181
+ /**
182
+ * all nodes that are involved with this editor. we format all of them,
183
+ * and if you edit one we might switch the colors in the others as
184
+ * references change.
185
+ */
186
+ protected nodes: NodeDescriptor[] = [];
187
+
188
+ /**
189
+ * address of cell we're editing, if we're editing a cell
190
+ */
191
+ public target_address?: ICellAddress;
192
+
193
+ /**
194
+ * assume we're editing a formula. this is for the external editor.
195
+ * if we switch the formula bar to inherit from this class, it should
196
+ * be false.
197
+ */
198
+ protected assume_formula = false;
199
+
200
+ /**
201
+ * this flag indicates we're editing a formula, which starts with `=`.
202
+ */
203
+ protected text_formula = false;
204
+
205
+ /**
206
+ * this has changed -- we don't have an internal field. instead we'll
207
+ * check when called. it's slightly more expensive but should be
208
+ * relatively rare.
209
+ */
210
+ public get selecting(): boolean {
211
+
212
+ if (this.assume_formula) {
213
+ return true; // always selecting
214
+ }
215
+
216
+ if (!this.text_formula) {
217
+ return false;
218
+ }
219
+
220
+ // FIXME: also if you change the selection. our insert routine
221
+ // handles that but this will return false. the test is "is the
222
+ // cursor in or at the end of a reference?"
223
+
224
+ if (this.active_editor && this.active_editor.node === document.activeElement) {
225
+
226
+ const selection = window.getSelection();
227
+ const count = selection?.rangeCount;
228
+
229
+ if (count) {
230
+
231
+ const range = selection?.getRangeAt(0);
232
+ const element = range?.endContainer instanceof HTMLElement ? range.endContainer :
233
+ range.endContainer?.parentElement;
234
+
235
+ // this is a reference, assume we're selecting (we will replace)
236
+ if (element?.dataset.reference !== undefined) {
237
+ return true;
238
+ }
239
+
240
+ // we may be able to use the selection directly
241
+ /*
242
+ if (range?.endContainer instanceof Text) {
243
+ const str = (range.endContainer.textContent?.substring(0, range.endOffset) || '').trim();
244
+ if (str.length && Editor2.FormulaChars.includes(str[str.length - 1])) {
245
+ return true;
246
+ }
247
+ }
248
+ */
249
+
250
+ // start, not end
251
+
252
+ if (range?.startContainer instanceof Text) {
253
+ const str = (range.startContainer.textContent?.substring(0, range.startOffset) || '').trim();
254
+ if (str.length && Editor.FormulaChars.includes(str[str.length - 1])) {
255
+ return true;
256
+ }
257
+ }
258
+ else {
259
+ console.info("mark 21", range);
260
+ }
261
+
262
+ }
263
+
264
+ const text = this.SubstringToCaret2(this.active_editor.node)[1].trim();
265
+ if (text.length) {
266
+ const char = text[text.length - 1];
267
+ return Editor.FormulaChars.includes(char);
268
+ }
269
+
270
+ }
271
+
272
+ return false;
273
+ }
274
+
275
+ /** internal. not sure why we have a shadow property. */
276
+ protected composite_dependencies: Area[] = [];
277
+
278
+ /** accessor */
279
+ public get dependencies(): Area[] {
280
+ return this.composite_dependencies;
281
+ }
282
+
283
+ /** reference to model parser */
284
+ public parser: Parser;
285
+
286
+ constructor(
287
+ public model: DataModel,
288
+ public view: ViewModel,
289
+ public autocomplete?: Autocomplete ){
290
+
291
+ super();
292
+
293
+ this.parser = model.parser;
294
+ // this.measurement_node = document.createElement('div');
295
+
296
+ }
297
+
298
+ public FocusEditor(): void {
299
+ if (this.active_editor) {
300
+ this.active_editor.node.focus();
301
+ }
302
+ }
303
+
304
+ /**
305
+ * add an event listener to the node. these are stored so we can remove
306
+ * them later if the node is disconnected.
307
+ *
308
+ * listeners moved to node descriptors so we can have multiple sets.
309
+ */
310
+ protected RegisterListener<K extends keyof HTMLElementEventMap>(descriptor: NodeDescriptor, key: K, handler: (event: HTMLElementEventMap[K]) => any) {
311
+ descriptor.node.addEventListener(key, handler);
312
+ if (!descriptor.listeners) {
313
+ descriptor.listeners = new Map();
314
+ }
315
+ descriptor.listeners.set(key, handler as GenericEventListener);
316
+ }
317
+
318
+ protected SelectAll(node: HTMLElement) {
319
+ const selection = window.getSelection();
320
+ const range = document.createRange();
321
+ range.selectNode(node);
322
+ selection?.removeAllRanges();
323
+ selection?.addRange(range);
324
+ }
325
+
326
+ protected SetCaret(
327
+ start: { node: ChildNode, offset: number },
328
+ end?: { node: ChildNode, offset: number }) {
329
+
330
+ const selection = window.getSelection();
331
+ const range = document.createRange();
332
+
333
+ const FirstTextNode = (node: ChildNode) => {
334
+ let target: Node = node;
335
+ while (target && !(target instanceof Text) && !!target.firstChild) {
336
+ target = target.firstChild;
337
+ }
338
+ return target;
339
+ };
340
+
341
+ const start_node = FirstTextNode(start.node);
342
+
343
+ if (end) {
344
+ const end_node = FirstTextNode(end.node);
345
+ if (selection && range) {
346
+ range.setStart(start_node, start.offset);
347
+ range.setEnd(end_node, end.offset);
348
+ selection.removeAllRanges();
349
+ selection.addRange(range);
350
+ }
351
+ }
352
+ else {
353
+ if (selection && range) {
354
+ range.setStart(start_node, start.offset);
355
+ range.setEnd(start_node, start.offset);
356
+ range.collapse(true);
357
+ selection.removeAllRanges();
358
+ selection.addRange(range);
359
+ }
360
+ }
361
+
362
+ };
363
+
364
+ /**
365
+ * not sure what the ID was, we don't use it atm
366
+ */
367
+ public InsertReference(reference: string, id?: number) {
368
+
369
+ if (!this.active_editor) {
370
+ return;
371
+ }
372
+
373
+ const selection = window.getSelection();
374
+ if (!selection) {
375
+ throw new Error('error getting selection');
376
+ }
377
+
378
+ if (selection.rangeCount === 0) {
379
+ // console.warn('range count is 0');
380
+ return '';
381
+ }
382
+
383
+ let range = selection.getRangeAt(0);
384
+ const text = this.active_editor.node.textContent || '';
385
+
386
+ // so what we are doing here depends on where the caret is.
387
+ // if the caret is in a reference (address, range, &c) then
388
+ // we replace the reference. that seems like the logical thing
389
+ // to do.
390
+
391
+ // if the caret is not in a reference, then we need to insert/append
392
+ // it at the caret position. should we replace existing stuff? what
393
+ // if it's in a literal? ...
394
+
395
+ // maybe the criteria should be "is there a range selection", and if
396
+ // so, replace the range selection -- otherwise, insert the reference
397
+ // (possibly with a delimeter, space, or operator?)
398
+
399
+ // A: easiest case: selection is in a reference. replace it.
400
+
401
+ // actually the first case should be the range selection, since that
402
+ // might include _more_ than an existing reference, and we want to
403
+ // replace the entire range selection.
404
+
405
+ if (range.startContainer instanceof Text) {
406
+
407
+ // first case: range selected
408
+ if (!range.collapsed && range.startOffset < range.endOffset) {
409
+
410
+ const substrings = this.SubstringToCaret2(this.active_editor.node);
411
+
412
+ /*
413
+ // console.info('case 1');
414
+
415
+ const substring_1 = this.SubstringToCaret(this.editor_node, true);
416
+ const substring_2 = this.SubstringToCaret(this.editor_node, false);
417
+
418
+ const test = this.SubstringToCaret2(this.editor_node);
419
+ console.info(
420
+ (test[0] === substring_1 && test[1] === substring_2) ? 'GOOD' : 'BAD',
421
+ { test, substring_1, substring_2 });
422
+ */
423
+
424
+ this.active_editor.node.textContent = substrings[0] + reference + text.substring(substrings[1].length);
425
+
426
+ this.SetCaret({
427
+ node: this.active_editor.node,
428
+ offset: substrings[0].length,
429
+ }, {
430
+ node: this.active_editor.node,
431
+ offset: substrings[0].length + reference.length
432
+ });
433
+
434
+ }
435
+ else {
436
+
437
+ // check if we're in a reference node; if so, replace
438
+
439
+ const parent = range.startContainer.parentElement;
440
+ if (parent instanceof HTMLElement && parent.dataset.reference) {
441
+
442
+ // console.info('case 2');
443
+
444
+ // replace text
445
+ parent.textContent = reference;
446
+ this.SetCaret({
447
+ node: parent,
448
+ offset: reference.length,
449
+ });
450
+
451
+ }
452
+ else {
453
+
454
+ // console.info('case 3;', {sc: range.startContainer, text: range.startContainer.data, parent});
455
+
456
+ // otherwise, insert at caret. should we add a delimeter? it
457
+ // probably depends on what's immediately preceding the caret.
458
+ // UPDATE: what about following the caret?
459
+
460
+ const substring = this.SubstringToCaret2(this.active_editor.node)[1];
461
+
462
+ let leader = '';
463
+ let trailer = '';
464
+
465
+ let trimmed = substring.trim();
466
+ if (trimmed.length) {
467
+ const char = trimmed[trimmed.length - 1];
468
+ if (!Editor.FormulaChars.includes(char)) {
469
+ if (substring.length === trimmed.length) {
470
+ leader = ' +';
471
+ }
472
+ else {
473
+ leader = '+';
474
+ }
475
+ }
476
+ }
477
+
478
+ // check after. this is a little different because we still
479
+ // want to set the caret at the end of the original reference.
480
+ // we need a flag.
481
+
482
+ // we can't insert a space, because that will break parsing (it
483
+ // will become invalid). I guess we could insert a space, and just
484
+ // accept that, but this seems like it works better (doing nothing).
485
+
486
+ /*
487
+ if (text.length > substring.length) {
488
+ const char = text[substring.length];
489
+ if (!Editor2.FormulaChars.includes(char) && !/\s/.test(char)) {
490
+ trailer = ' ';
491
+ }
492
+ }
493
+ */
494
+
495
+ this.active_editor.node.textContent = substring + leader + reference + text.substring(substring.length);
496
+ this.SetCaret({
497
+ node: this.active_editor.node,
498
+ offset: substring.length + reference.length + leader.length,
499
+ });
500
+
501
+ }
502
+
503
+ }
504
+ }
505
+ else {
506
+
507
+ // if startContainer is not text, that usually means the container
508
+ // is empty. I don't think there's any other case. so we can insert
509
+ // the text. we'll want to create a node, and we'll want to set the
510
+ // cursor at the end.
511
+
512
+ if (range.startContainer instanceof HTMLElement) {
513
+ range.startContainer.textContent = reference;
514
+ this.SetCaret({
515
+ node: range.startContainer,
516
+ offset: reference.length,
517
+ });
518
+ }
519
+
520
+ else {
521
+ console.warn("unexpected range start container", range.startContainer);
522
+ }
523
+
524
+ }
525
+
526
+ // there may be some cases where we don't need to do this
527
+
528
+ this.UpdateText(this.active_editor)
529
+ this.UpdateColors();
530
+
531
+ // this does not raise an input event. probably because we're calling
532
+ // it from script. but we might be pretty disconnected from the owner.
533
+ // can we use a synthentic event that matches one of the real ones?
534
+
535
+ // A: yes, except that isTrusted will evaluate to false. which I guess
536
+ // is fine, in this context?
537
+
538
+ // make sure to do this after updating so we have a current list of
539
+ // references attached to the node
540
+
541
+ this.active_editor.node.dispatchEvent(new Event('input', {
542
+ bubbles: true,
543
+ cancelable: true,
544
+ }));
545
+
546
+ }
547
+
548
+ /**
549
+ * this method does three things:
550
+ *
551
+ * (1) builds a flat list of references across all nodes
552
+ * (2) applies colors to formatted references
553
+ * (3) sends an event (if necessary, or forced)
554
+ *
555
+ * that's fine, but it needs a new name.
556
+ *
557
+ */
558
+ protected UpdateColors(force_event = false) {
559
+
560
+ // create a map of canonical label -> area
561
+
562
+ const map: Map<string, Area> = new Map();
563
+
564
+ // also create a map of label -> index
565
+
566
+ const indexes: Map<string, number> = new Map();
567
+
568
+ for (const support of this.nodes) {
569
+ for (const area of support.references || []) {
570
+ const label = this.model.AddressToLabel(area);
571
+ if (!map.has(label)) {
572
+ map.set(label, area);
573
+ indexes.set(label, indexes.size);
574
+ }
575
+ }
576
+ }
577
+
578
+ // FIXME: compare against current and short-circuit
579
+
580
+ // console.info({map, indexes});
581
+
582
+ // now apply colors to nodes
583
+
584
+ for (const entry of this.nodes) {
585
+ for (const node of Array.from(entry.node.childNodes)) {
586
+ if (node instanceof HTMLElement && node.dataset.reference) {
587
+ const index = indexes.get(node.dataset.reference);
588
+ node.dataset.highlightIndex = (typeof index === 'number') ? (index % 5 + 1).toString() : '?';
589
+ }
590
+ }
591
+
592
+ // this is a check for flushing text when we re-attach.
593
+ // @see AttachNode
594
+
595
+ entry.check = entry.node.innerHTML.length;
596
+
597
+ }
598
+
599
+ // dependencies is just the single list
600
+
601
+ const list = Array.from(map.values());
602
+
603
+ if (!force_event) {
604
+ if (JSON.stringify(this.composite_dependencies) === JSON.stringify(list)) {
605
+ return;
606
+ }
607
+ }
608
+
609
+ this.composite_dependencies = list;
610
+
611
+ this.Publish({ type: 'update', dependencies: this.composite_dependencies });
612
+
613
+ }
614
+
615
+ /**
616
+ * get a list of all references in the text (actually in the parse result,
617
+ * since we have that). stores the list in the node descriptor (and in
618
+ * the node dataset).
619
+ *
620
+ * returns a list of the references in parse result mapped to normalized
621
+ * address labels. those can be used to identify identical references when
622
+ * we highlight later.
623
+ *
624
+ * @param parse_result
625
+ * @returns
626
+ */
627
+ protected UpdateDependencies(descriptor: NodeDescriptor, parse_result: ParseResult) {
628
+
629
+ const reference_list: Array<UnitRange|UnitAddress> = [];
630
+
631
+ for (const unit of parse_result.full_reference_list || []) {
632
+ switch (unit.type) {
633
+ case 'address':
634
+ case 'range':
635
+ {
636
+ const start = unit.type === 'range' ? unit.start : unit;
637
+ if (!start.sheet_id) {
638
+ if (start.sheet) {
639
+ start.sheet_id = this.model.sheets.Find(start.sheet)?.id || 0;
640
+ }
641
+ else {
642
+ start.sheet_id = this.view.active_sheet.id;
643
+ }
644
+ }
645
+ reference_list.push(unit);
646
+ break;
647
+ }
648
+
649
+ case 'structured-reference':
650
+ if (this.target_address) {
651
+ const reference = this.model.ResolveStructuredReference(unit, this.target_address);
652
+ if (reference) {
653
+ reference_list.push(reference);
654
+ }
655
+ }
656
+ else {
657
+ console.info('target address not set');
658
+ }
659
+ break;
660
+
661
+ case 'identifier':
662
+ {
663
+ const named_range = this.model.named_ranges.Get(unit.name);
664
+ if (named_range) {
665
+ if (named_range.count === 1) {
666
+ reference_list.push({
667
+ type: 'address',
668
+ ...named_range.start,
669
+ label: unit.name,
670
+ position: unit.position,
671
+ id: unit.id,
672
+ });
673
+ }
674
+ else {
675
+ reference_list.push({
676
+ type: 'range',
677
+ start: {
678
+ type: 'address',
679
+ position: unit.position,
680
+ id: unit.id,
681
+ label: unit.name,
682
+ ...named_range.start,
683
+ },
684
+ end: {
685
+ type: 'address',
686
+ position: unit.position,
687
+ label: unit.name,
688
+ id: unit.id,
689
+ ...named_range.end,
690
+ },
691
+ label: unit.name,
692
+ position: unit.position,
693
+ id: unit.id,
694
+ });
695
+ }
696
+ }
697
+
698
+ break;
699
+ }
700
+
701
+ }
702
+ }
703
+
704
+ // how could this ever be out of order? (...)
705
+ reference_list.sort((a, b) => a.position - b.position);
706
+
707
+ // flat list, unique
708
+ const references: Area[] = [];
709
+
710
+ // set for matching
711
+ const list: Set<string> = new Set();
712
+
713
+ // for the result, map of reference to normalized address label
714
+ const map: Map<ExpressionUnit, string> = new Map();
715
+
716
+ for (const entry of reference_list) {
717
+
718
+ const label = this.model.AddressToLabel(entry, this.view.active_sheet);
719
+ const area = IsCellAddress(entry) ?
720
+ new Area(entry) :
721
+ new Area(entry.start, entry.end);
722
+
723
+ // add to references once
724
+
725
+ if (!list.has(label)) {
726
+ references.push(area);
727
+ list.add(label);
728
+ }
729
+
730
+ // but keep a map
731
+ map.set(entry, label);
732
+
733
+ }
734
+
735
+ this.UpdateReferences(descriptor, references);
736
+
737
+ return map;
738
+
739
+ }
740
+
741
+ /**
742
+ * store the set of references, and store in the node dataset for
743
+ * external clients.
744
+ *
745
+ * @param descriptor
746
+ * @param references
747
+ * @param options
748
+ */
749
+ protected UpdateReferences(descriptor: NodeDescriptor, references: Area[] = []) {
750
+ descriptor.node.dataset.references = JSON.stringify(references.map(entry => this.model.AddressToLabel(entry)));
751
+ descriptor.references = references;
752
+ }
753
+
754
+ /**
755
+ * reformat text to highlight, which involves tinkering with
756
+ * node structure. we're probably doing this more than necessary;
757
+ * we might consider editing the existing structure, rather than
758
+ * throwing it away every time.
759
+ *
760
+ */
761
+ protected UpdateText(
762
+ // node: HTMLElement,
763
+ descriptor: NodeDescriptor,
764
+ options: Partial<UpdateTextOptions> = {}) {
765
+
766
+ const node = descriptor.node;
767
+ const text = node.textContent || '';
768
+
769
+ // set this flag so we can use it in `get selected()`
770
+
771
+ this.text_formula = text[0] === '=';
772
+
773
+ if (this.active_editor && !this.assume_formula) {
774
+ this.active_editor.node.spellcheck = !(this.text_formula);
775
+ }
776
+
777
+ // this is a short-circuit so we don't format the same text twice.
778
+ // but there are some problems when you assign the same text over,
779
+ // especially if the text is empty.
780
+ //
781
+ // to unset this field make sure to set it to `undefined` instead
782
+ // of any empty string, so it will expressly not match an empty string.
783
+
784
+ if (text === descriptor.formatted_text) {
785
+
786
+ // fix selection behavior for tabbing
787
+ // for some reason this is too aggressive, it's happening when
788
+ // we _should_ have a selection
789
+
790
+ /*
791
+ if (node === this.editor_node && node === document.activeElement) {
792
+ const substr = this.SubstringToCaret(node);
793
+ const substr2 = this.SubstringToCaret(node, true);
794
+ if (text.length && substr === '' && substr2 === '') {
795
+ this.SelectAll(node);
796
+ }
797
+ }
798
+ */
799
+
800
+ // is there a case where we'd want to autocomplete here? (...)
801
+
802
+ return;
803
+
804
+ }
805
+
806
+ // I wonder if this should be done asynchronously... we generally
807
+ // have pretty short strings, so maybe not a big deal
808
+
809
+ const [substring_start, substring_end] = this.SubstringToCaret2(node);
810
+
811
+ // console.info({text, substr, substr2});
812
+
813
+ let caret_start = substring_start.length;
814
+ let caret_end = substring_end.length;
815
+
816
+ // this is a little hacky
817
+ if (caret_start === 0 && caret_end === 0) {
818
+ caret_end = text.length;
819
+ }
820
+
821
+ if (!text) {
822
+ this.UpdateReferences(descriptor); // flush
823
+ }
824
+ else {
825
+ const parse_result = this.parser.Parse(text);
826
+
827
+ if (parse_result.expression) {
828
+
829
+ const normalized_labels = this.UpdateDependencies(descriptor, parse_result);
830
+
831
+ // the parser will drop a leading = character, so be
832
+ // sure to add that back if necessary
833
+
834
+ const offset = (text[0] === '=' ? 1 : 0);
835
+
836
+ let start = 0;
837
+
838
+ let selection_start: { node: ChildNode, offset: number } | undefined;
839
+ let selection_end: { node: ChildNode, offset: number } | undefined;
840
+
841
+ // let selection_node: ChildNode|null = null;
842
+ // let selection_offset = 0;
843
+
844
+ let text_index = 0;
845
+ let last_text_node: Text|undefined;
846
+
847
+ const fragment = document.createDocumentFragment();
848
+
849
+ const AddNode = (text: string, type = 'text', reference = '', force_selection = false) => {
850
+
851
+ const text_node = document.createTextNode(text);
852
+
853
+ if (force_selection || ((caret_start > text_index || (caret_start === 0 && text_index === 0)) && caret_start <= text_index + text.length)) {
854
+ selection_start = {
855
+ offset: caret_start - text_index,
856
+ node: text_node,
857
+ };
858
+ }
859
+
860
+ if (caret_end > text_index && caret_end <= text_index + text.length ) {
861
+ selection_end = {
862
+ offset: caret_end - text_index,
863
+ node: text_node,
864
+ };
865
+ }
866
+
867
+ if (type !== 'text') {
868
+
869
+ const span = document.createElement('span');
870
+
871
+ if (reference) {
872
+ span.dataset.reference = reference;
873
+ }
874
+
875
+ span.className = type;
876
+ span.appendChild(text_node);
877
+ fragment.appendChild(span);
878
+
879
+ }
880
+ else {
881
+ fragment.appendChild(text_node);
882
+ }
883
+
884
+ last_text_node = text_node;
885
+ text_index += text.length;
886
+
887
+ };
888
+
889
+ this.parser.Walk(parse_result.expression, (unit: ExpressionUnit) => {
890
+
891
+ if (unit.type === 'missing' || unit.type === 'group' || unit.type === 'dimensioned') {
892
+ return true;
893
+ }
894
+
895
+ const pos = unit.position + offset;
896
+ const part = text.substring(start, pos);
897
+
898
+ let label = '';
899
+ let type: string = unit.type;
900
+ let reference = '';
901
+
902
+ switch (unit.type) {
903
+ case 'identifier':
904
+ case 'call':
905
+
906
+ // FIXME: canonicalize (optionally)
907
+ label = text.substring(pos, pos + unit.name.length);
908
+ break;
909
+
910
+ case 'literal':
911
+ if (typeof unit.value === 'string') {
912
+ label = text.substring(pos, pos + unit.value.length + 2);
913
+ type = 'string';
914
+ }
915
+ else {
916
+ return false;
917
+ }
918
+ break;
919
+
920
+ case 'address':
921
+ case 'range':
922
+ case 'structured-reference':
923
+ reference = normalized_labels.get(unit) || '???';
924
+
925
+ /*
926
+ {
927
+ const index = indexes.get(unit);
928
+ if (typeof index === 'number') {
929
+ type += ` highlight-${(index % 5) + 1}`
930
+ }
931
+ }
932
+ */
933
+
934
+ // TODO: validate (optionally)
935
+ label = options.rewrite_addresses ? unit.label :
936
+ text.substring(pos, pos + unit.label.length);
937
+
938
+ break;
939
+
940
+ default:
941
+ // console.info('unhandled', unit.type);
942
+ return true;
943
+
944
+ }
945
+
946
+ AddNode(part);
947
+ AddNode(label, type, reference);
948
+ start = pos + label.length;
949
+
950
+ return unit.type !== 'range';
951
+
952
+ });
953
+
954
+ if (start < text.length) {
955
+ AddNode(text.substring(start));
956
+ }
957
+
958
+ if (!selection_start) {
959
+
960
+ // console.info('no selection node');
961
+
962
+ if (last_text_node) {
963
+
964
+ selection_start = {
965
+ node: last_text_node,
966
+ offset: (last_text_node.data || '').length,
967
+ };
968
+
969
+ // console.info('using last node');
970
+
971
+ }
972
+ else {
973
+ // console.info('adding next selection node');
974
+ AddNode('', undefined, '', true);
975
+ }
976
+ }
977
+
978
+ node.textContent = '';
979
+ node.appendChild(fragment);
980
+
981
+ if (selection_start && !options.format_only && node === this.active_editor?.node) {
982
+ this.SetCaret(selection_start, selection_end);
983
+
984
+ // we were doing this for the external editor, and it was useful
985
+ // because those editors don't grow. but it makes the spreadsheet
986
+ // scroll when it's used in the ICE/overlay. maybe a flag?
987
+
988
+ // (selection_end || selection_start).node.parentElement?.scrollIntoView();
989
+
990
+ }
991
+
992
+ }
993
+ else {
994
+ // console.warn("expression failed", text);
995
+ }
996
+ }
997
+
998
+ descriptor.formatted_text = text;
999
+
1000
+ //
1001
+
1002
+ const matcher = this.autocomplete_matcher;
1003
+ if (matcher) {
1004
+ Promise.resolve().then(() => {
1005
+ const exec_result = matcher.Exec({ text, cursor: substring_end.length });
1006
+ const node =
1007
+ this.NodeAtIndex(exec_result.completions?.length ?
1008
+ (exec_result.position || 0) :
1009
+ (exec_result.function_position || 0));
1010
+ this.Autocomplete(exec_result, node);
1011
+ });
1012
+ }
1013
+
1014
+ }
1015
+
1016
+ protected NodeAtIndex(index: number): Node|undefined {
1017
+ const children = this.active_editor?.node.childNodes || [];
1018
+ for (let i = 0; i < children.length; i++) {
1019
+ const len = children[i].textContent?.length || 0;
1020
+ if (len > index) {
1021
+ return children[i];
1022
+ }
1023
+ index -= len;
1024
+ }
1025
+ return undefined;
1026
+ }
1027
+
1028
+ protected AcceptAutocomplete(ac_result: AutocompleteResult) {
1029
+
1030
+ if (!this.active_editor) return;
1031
+
1032
+ let type = DescriptorType.Function;
1033
+ if (ac_result.data && ac_result.data.completions) {
1034
+ for (const completion of ac_result.data.completions) {
1035
+ if (completion.name.toLowerCase() === ac_result.value?.toLowerCase()) {
1036
+ type = completion.type || DescriptorType.Function;
1037
+ break;
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ // since this only happens when typing, we know that there's a single
1043
+ // cursor position, and it's in a text node. can we use that reduction to
1044
+ // simplify how we insert? it's probably unecessary to highlight...
1045
+ //
1046
+ // at least in the case of functions. if we're inserting a named reference,
1047
+ // then we do need to highlight. so.
1048
+
1049
+ const start = ac_result.data?.position || 0;
1050
+ const end = start + (ac_result.data?.token?.length || 0);
1051
+
1052
+ const insertion = (type === DescriptorType.Token) ? ac_result.value : ac_result.value + '(';
1053
+
1054
+ const text = this.active_editor.node.textContent || '';
1055
+ let adjusted = text.substring(0, start) + insertion;
1056
+ let caret = adjusted.length;
1057
+ adjusted += text.substring(end);
1058
+
1059
+ this.active_editor.node.textContent = adjusted;
1060
+ this.SetCaret({node: this.active_editor.node, offset: caret});
1061
+
1062
+ this.autocomplete?.Hide();
1063
+
1064
+ this.UpdateText(this.active_editor);
1065
+ this.UpdateColors();
1066
+
1067
+ }
1068
+
1069
+ /** called when there's AC data to display (or tooltip) */
1070
+ protected Autocomplete(data: AutocompleteExecResult, target_node?: Node): void {
1071
+
1072
+ if (!this.container_node || !this.autocomplete) {
1073
+ return;
1074
+ }
1075
+
1076
+ let client_rect: DOMRect;
1077
+ if (target_node?.nodeType === Node.ELEMENT_NODE) {
1078
+ client_rect = (target_node as Element).getBoundingClientRect();
1079
+ }
1080
+ else {
1081
+ client_rect = this.container_node.getBoundingClientRect();
1082
+ }
1083
+
1084
+ // console.info({target_node, client_rect});
1085
+
1086
+ const rect = new Rectangle(
1087
+ Math.round(client_rect.left),
1088
+ Math.round(client_rect.top),
1089
+ client_rect.width, client_rect.height);
1090
+
1091
+ this.autocomplete.Show(this.AcceptAutocomplete.bind(this), data, rect);
1092
+
1093
+ }
1094
+
1095
+ /**
1096
+ * this version gets substrings to both selection points.
1097
+ *
1098
+ * @param node
1099
+ * @returns [substring to start of selection, substring to end of selection]
1100
+ */
1101
+ protected SubstringToCaret2(node: HTMLElement): [string, string] {
1102
+
1103
+ const result: [string, string] = ['', ''];
1104
+
1105
+ if (node !== document.activeElement || node !== this.active_editor?.node) {
1106
+ return result;
1107
+ }
1108
+
1109
+ // is there a way to do this without recursing? (...)
1110
+ // how about string concat instead of array join, it's faster in
1111
+ // chrome (!)
1112
+
1113
+ let complete = [ false, false ];
1114
+
1115
+ const Consume = (element: Node, range: Range) => {
1116
+
1117
+ if (element === range.startContainer) {
1118
+ result[0] += (element.textContent || '').substring(0, range.startOffset);
1119
+ complete[0] = true;
1120
+ }
1121
+ if (element === range.endContainer) {
1122
+ result[1] += (element.textContent || '').substring(0, range.endOffset);
1123
+ complete[1] = true;
1124
+ }
1125
+
1126
+ if (complete[0] && complete[1]) {
1127
+ return;
1128
+ }
1129
+
1130
+ if (element instanceof Text) {
1131
+ const text = element.textContent || '';
1132
+ if (!complete[0]) {
1133
+ result[0] += text;
1134
+ }
1135
+ if (!complete[1]) {
1136
+ result[1] += text;
1137
+ }
1138
+ }
1139
+ else if (element.hasChildNodes()) {
1140
+ for (const child of Array.from(element.childNodes)) {
1141
+ Consume(child, range);
1142
+ if (complete[0] && complete[1]) {
1143
+ return;
1144
+ }
1145
+ }
1146
+ }
1147
+ };
1148
+
1149
+ const selection = window.getSelection();
1150
+ if (selection?.rangeCount ?? 0 > 0) {
1151
+ const range = selection?.getRangeAt(0);
1152
+ if (range) {
1153
+ Consume(node, range);
1154
+ }
1155
+ }
1156
+
1157
+ return result;
1158
+ }
1159
+
1160
+ /* *
1161
+ * get text up to the selection. optionally use the start of the
1162
+ * selection. new version does not require cloning, we can drop the
1163
+ * measurement node.
1164
+ *
1165
+ * @param node
1166
+ * @param start
1167
+ * @returns
1168
+ * /
1169
+ protected SubstringToCaret(node: HTMLElement, start = false): string {
1170
+
1171
+ if (node !== document.activeElement) {
1172
+ return '';
1173
+ }
1174
+
1175
+ // is there a way to do this without recursing? (...)
1176
+ // how about string concat instead of array join, it's faster in chrome (!)
1177
+
1178
+ const Consume = (element: Node, target: Node, offset: number, parts: string[]): boolean => {
1179
+ if (element === target) {
1180
+ parts.push((element.textContent || '').substring(0, offset));
1181
+ return false;
1182
+ }
1183
+ else if (element instanceof Text) {
1184
+ parts.push(element.textContent || '');
1185
+ return true;
1186
+ }
1187
+ else if (element.hasChildNodes()) {
1188
+ for (const child of element.childNodes) {
1189
+ const result = Consume(child, target, offset, parts);
1190
+ if (!result) {
1191
+ return false;
1192
+ }
1193
+ }
1194
+ return true;
1195
+ }
1196
+ return false;
1197
+ };
1198
+
1199
+ const selection = window.getSelection();
1200
+ if (selection?.rangeCount ?? 0 > 0) {
1201
+
1202
+ const range = selection?.getRangeAt(0);
1203
+ if (range) {
1204
+ let [target, offset] = start ?
1205
+ [range.startContainer, range.startOffset] :
1206
+ [range.endContainer, range.endOffset];
1207
+
1208
+ const parts: string[] = [];
1209
+ Consume(node, target, offset, parts);
1210
+ return parts.join('');
1211
+
1212
+ }
1213
+
1214
+ }
1215
+
1216
+ return '';
1217
+
1218
+ }
1219
+ */
1220
+
1221
+ /* *
1222
+ * we've been carrying this function around for a while. is this
1223
+ * still the best way to do this in 2023? (...)
1224
+ *
1225
+ * get text substring to caret position, irrespective of node structure
1226
+ *
1227
+ * @param start - use the start of the selection instead of the end
1228
+ * /
1229
+ protected SubstringToCaret(node: HTMLElement, start = false): string {
1230
+
1231
+ if (node !== this.editor_node || node !== document.activeElement) {
1232
+ return '';
1233
+ }
1234
+
1235
+ // we could probably shortcut if text is empty
1236
+
1237
+ const selection = window.getSelection();
1238
+ if (!selection) {
1239
+ throw new Error('error getting selection');
1240
+ }
1241
+
1242
+ if (selection.rangeCount === 0) {
1243
+ // console.warn('range count is 0');
1244
+ return '';
1245
+ }
1246
+
1247
+ const range = selection.getRangeAt(0);
1248
+ const preCaretRange = range.cloneRange();
1249
+
1250
+ preCaretRange.selectNodeContents(node);
1251
+ if (start) {
1252
+ preCaretRange.setEnd(range.startContainer, range.startOffset);
1253
+ }
1254
+ else {
1255
+ preCaretRange.setEnd(range.endContainer, range.endOffset);
1256
+ }
1257
+
1258
+ this.measurement_node.textContent = '';
1259
+ this.measurement_node.appendChild(preCaretRange.cloneContents());
1260
+
1261
+ const result = this.measurement_node.textContent || '';
1262
+ const result2 = this.SubstringToCaret2(node, start);
1263
+
1264
+ // console.info('X', result === result2, {result, result2});
1265
+ if (result !== result2) {
1266
+ throw new Error('mismatch');
1267
+ }
1268
+
1269
+ return result;
1270
+
1271
+ // return this.measurement_node.textContent || '';
1272
+
1273
+ }
1274
+ */
1275
+
1276
+ }