@trebco/treb 27.5.2 → 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.
- package/dist/treb-spreadsheet.mjs +14 -14
- package/dist/treb.d.ts +17 -3
- package/package.json +3 -3
- package/treb-calculator/src/calculator.ts +15 -104
- package/treb-embed/src/embedded-spreadsheet.ts +30 -30
- package/treb-embed/style/formula-bar.scss +2 -0
- package/treb-embed/style/theme-defaults.scss +46 -15
- package/treb-grid/src/editors/editor.ts +1276 -0
- package/treb-grid/src/editors/external_editor.ts +113 -0
- package/treb-grid/src/editors/formula_bar.ts +450 -474
- package/treb-grid/src/editors/overlay_editor.ts +437 -512
- package/treb-grid/src/index.ts +1 -1
- package/treb-grid/src/layout/base_layout.ts +1 -1
- package/treb-grid/src/render/tile_renderer.ts +7 -1
- package/treb-grid/src/types/data_model.ts +130 -3
- package/treb-grid/src/types/external_editor_config.ts +47 -0
- package/treb-grid/src/types/grid.ts +91 -39
- package/treb-grid/src/types/grid_base.ts +1 -2
- package/treb-grid/src/types/scale-control.ts +1 -1
- package/treb-grid/src/util/dom_utilities.ts +58 -25
- package/treb-grid/src/editors/formula_editor_base.ts +0 -912
- package/treb-grid/src/types/external_editor.ts +0 -27
|
@@ -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
|
+
}
|