@fw-components/formula-editor 2.0.3-formula-editor.1

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/src/helpers.ts ADDED
@@ -0,0 +1,62 @@
1
+ export class Stack<Type> {
2
+ private _elements: Type[] = [];
3
+
4
+ push(item: Type): void {
5
+ this._elements.push(item);
6
+ }
7
+
8
+ pop(): Type | undefined {
9
+ return this._elements.pop();
10
+ }
11
+
12
+ top(): Type | undefined {
13
+ return this._elements.at(-1);
14
+ }
15
+
16
+ isEmpty(): boolean {
17
+ return this._elements.length == 0;
18
+ }
19
+
20
+ print(): void {
21
+ console.log(this._elements);
22
+ }
23
+ }
24
+
25
+ export class Queue<Type> {
26
+ private _elements: { [key: number]: Type } = {};
27
+ private _head: number = 0;
28
+ private _tail: number = 0;
29
+
30
+ enqueue(item: Type): void {
31
+ this._elements[this._tail] = item;
32
+ this._tail++;
33
+ }
34
+
35
+ dequeue(): Type | undefined {
36
+ if (this._tail === this._head) return undefined;
37
+
38
+ const element = this._elements[this._head];
39
+ delete this._elements[this._head];
40
+ this._head++;
41
+
42
+ return element;
43
+ }
44
+
45
+ peek(): Type {
46
+ return this._elements[this._head];
47
+ }
48
+
49
+ isEmpty(): boolean {
50
+ return this._head == this._tail;
51
+ }
52
+
53
+ print(): void {
54
+ console.log(this._elements);
55
+ }
56
+ }
57
+
58
+ export enum Expectation {
59
+ VARIABLE,
60
+ OPERATOR,
61
+ UNDEFINED,
62
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,465 @@
1
+ import Big from "big.js";
2
+ import { Expectation, Queue, Stack } from "./helpers.js";
3
+ import { Recommender } from "./recommendor.js";
4
+
5
+ export interface ParseResult {
6
+ recommendations: string[] | null;
7
+ formattedContent: HTMLBodyElement | null;
8
+ formattedString: string | null;
9
+ newCursorPosition: number;
10
+ errorString: string | null;
11
+ }
12
+
13
+ export interface CalculateResult {
14
+ result: number | undefined;
15
+ errorString: string | null;
16
+ }
17
+
18
+ export class Parser {
19
+ constructor(variables: Map<string, number>, minSuggestionLen: number) {
20
+ this.variables = variables;
21
+
22
+ this._recommender = new Recommender(this.variables, minSuggestionLen);
23
+ }
24
+
25
+ private _recommender: Recommender;
26
+
27
+ variables: Map<string, number>;
28
+ mathematicalOperators: Set<string> = new Set(["^", "+", "-", "*", "/"]);
29
+ operatorPrecedence: { [key: string]: number } = {
30
+ "^": 3,
31
+ "/": 2,
32
+ "*": 2,
33
+ "+": 1,
34
+ "-": 1,
35
+ };
36
+
37
+ parseInput(
38
+ formula: string,
39
+ prevCurPos: number | null = null,
40
+ recommendation: string | null = null
41
+ ): ParseResult {
42
+ let tokens = formula.split(/([-+(),*^/:?\s])/g);
43
+
44
+ // Stores the positions of opening parentheses. This allows us to
45
+ // show "Unclosed parenthesis error" for positions which are far behind
46
+ // our current token
47
+ let parentheses = new Stack<number>();
48
+
49
+ // The HTML formatted string which we eventually show on the view.
50
+ let formattedString = ``;
51
+
52
+ // The expectation that we have for the current token.
53
+ let expectation = Expectation.VARIABLE;
54
+
55
+ // Position of the current token in the formula string.
56
+ let currentPosition = 0;
57
+
58
+ // Previous 'token' (not a space or a new line) that we just encountered.
59
+ let previousToken = "";
60
+
61
+ // The object that we return as the output of the parsing result.
62
+ let parseOutput: ParseResult = {
63
+ recommendations: null,
64
+ formattedContent: null,
65
+ formattedString: null,
66
+ newCursorPosition: prevCurPos ?? -1,
67
+ errorString: null,
68
+ };
69
+
70
+ console.log(tokens);
71
+
72
+ tokens.forEach((token) => {
73
+ // It is a number is either it's in the defined variables, or
74
+ // it's a valid number literal.
75
+ let isNumber = this.variables.has(token) || !Number.isNaN(Number(token)),
76
+ isOperator = this.mathematicalOperators.has(token),
77
+ isSpace = token.trim() == "",
78
+ isBracket = token == "(" || token == ")";
79
+
80
+ // We don't really want anything for the spaces, other than simply
81
+ // adding them back to the view.
82
+ if (isSpace) {
83
+ formattedString = `${formattedString}${token}`;
84
+ currentPosition += token.length;
85
+ return;
86
+ }
87
+
88
+ // If the cursor position is 'inside` the current token:
89
+ //
90
+ // 1. If we've got a recommendation to add, simply replace the
91
+ // word with the recommendation.
92
+ // 2. Ask the recommendor to fetch recommendations for this specific
93
+ // token/word.
94
+
95
+ if (
96
+ currentPosition <= prevCurPos! &&
97
+ currentPosition + token.length >= prevCurPos!
98
+ ) {
99
+ // If a recommendation was provided, replace the correspoding
100
+ // word with it and move the cursor forward, accordingly.
101
+ if (recommendation) {
102
+ // Since we are sure that the recommendation will always correspond
103
+ // to a variable.
104
+ isNumber = true;
105
+
106
+ // If the new cursor length somehow becomes larger than the
107
+ // length of the formula string, setting the caret to that
108
+ // length will move the caret to the start. Although this overflow
109
+ // won't happen, but still, this check prevents that.
110
+ parseOutput.newCursorPosition = Math.min(
111
+ parseOutput.newCursorPosition +
112
+ recommendation.length -
113
+ token.length,
114
+ formula.length + recommendation.length - token.length
115
+ );
116
+ token = recommendation;
117
+ recommendation = null;
118
+ }
119
+
120
+ // Fetch recommendations nonetheless.
121
+ parseOutput.recommendations =
122
+ this._recommender.getRecommendation(token);
123
+ console.log(parseOutput.recommendations);
124
+ }
125
+
126
+ let tokenClassName = "";
127
+
128
+ // Don't check for errors if an error has already been encountered.
129
+ if (expectation != Expectation.UNDEFINED) {
130
+ // Unnecessary closing parenthesis
131
+ if (parentheses.isEmpty() && token == ")") {
132
+ parseOutput.errorString = `Unexpected ')' at pos: ${currentPosition}`;
133
+ tokenClassName += " error";
134
+ expectation = Expectation.UNDEFINED;
135
+ }
136
+
137
+ // Operator or ) after an operator. Eg: `23 / *` or `23 / )`
138
+ // Unary `+` and `-` are not an error as they might represent
139
+ // a positive or negative number respectively. But they will still
140
+ // be an error if the formula ends with them.
141
+ else if (
142
+ expectation == Expectation.VARIABLE &&
143
+ !isNumber &&
144
+ token != "(" &&
145
+ !(
146
+ (token == "-" || token == "+") &&
147
+ this.mathematicalOperators.has(previousToken)
148
+ )
149
+ ) {
150
+ parseOutput.errorString = `Expected variable/number at pos: ${currentPosition}`;
151
+ tokenClassName += " error";
152
+ expectation = Expectation.UNDEFINED;
153
+ }
154
+
155
+ // Number/Variable after the same. Eg: `a a` or `420 420`.
156
+ // Having a ) is fine. Eg: `23)` might be representing `(23 + 23)
157
+ else if (
158
+ expectation == Expectation.OPERATOR &&
159
+ !isOperator &&
160
+ token != ")"
161
+ ) {
162
+ parseOutput.errorString = `Expected mathematical operator at pos: ${currentPosition}`;
163
+ tokenClassName += " error";
164
+ expectation = Expectation.UNDEFINED;
165
+ }
166
+
167
+ // Unknown symbol/variable/word
168
+ else if (!(isNumber || isOperator || isBracket)) {
169
+ parseOutput.errorString = `Unknown word at pos: ${currentPosition}`;
170
+ tokenClassName += " error";
171
+ expectation = Expectation.UNDEFINED;
172
+ }
173
+
174
+ // The case of division by zero. Since we can't know if an expression evaluates
175
+ // to zero or not, that case can only be handled during calculation.
176
+ else if (
177
+ isNumber &&
178
+ previousToken == "/" &&
179
+ (this.variables.get(token) == 0 || Number(token) == 0)
180
+ ) {
181
+ parseOutput.errorString = `Division by zero at pos: ${currentPosition}`;
182
+ tokenClassName += " error";
183
+ expectation = Expectation.UNDEFINED;
184
+ }
185
+
186
+ // Empty brackets. Default might be takn as 0, but that will only make sense
187
+ // in addition and subtraction and not in other operators, so making this
188
+ // case an error makes more sense.
189
+ else if (previousToken == "(" && token == ")") {
190
+ parseOutput.errorString = `Empty brackets at position ${currentPosition}`;
191
+ tokenClassName += " error";
192
+ expectation = Expectation.UNDEFINED;
193
+ }
194
+ }
195
+
196
+ // Setting the expectation for the next token, if we have not encountered an
197
+ // error already.
198
+ if (expectation != Expectation.UNDEFINED) {
199
+ if (token == "(" || isOperator) {
200
+ expectation = Expectation.VARIABLE;
201
+ } else if (token == ")" || isNumber) {
202
+ expectation = Expectation.OPERATOR;
203
+ }
204
+ }
205
+
206
+ if (token == "(") {
207
+ parentheses.push(currentPosition);
208
+ tokenClassName += " bracket";
209
+ } else if (token == ")") {
210
+ parentheses.pop();
211
+ tokenClassName += " bracket";
212
+ } else if (isOperator) {
213
+ tokenClassName += " operator";
214
+ } else if (expectation == Expectation.UNDEFINED) {
215
+ tokenClassName += " error";
216
+ }
217
+
218
+ // Since not using ShadowDOM, having these specific class names will prevent
219
+ // name collision.
220
+ formattedString = `${formattedString}<span class="wysiwygInternals ${tokenClassName}">${token}</span>`;
221
+
222
+ currentPosition += token.length;
223
+ previousToken = token;
224
+ });
225
+
226
+ // If the formula ends with a mathematical operator, or has unclosed `(`
227
+ if (this.mathematicalOperators.has(previousToken)) {
228
+ parseOutput.errorString = "Unexpected ending of formula.";
229
+ } else if (!parentheses.isEmpty()) {
230
+ parseOutput.errorString = `Unclosed '(' at position: ${parentheses.top()}`;
231
+ }
232
+
233
+ const parser = new DOMParser();
234
+ const doc = parser.parseFromString(formattedString, "text/html");
235
+
236
+ parseOutput.formattedContent = doc.querySelector("body")!;
237
+ parseOutput.formattedString = formattedString;
238
+
239
+ return parseOutput;
240
+ }
241
+
242
+ buildRPN(formula: string): Queue<string> | null {
243
+ if (this.parseInput(formula).errorString) {
244
+ return null;
245
+ }
246
+
247
+ const tokens = formula
248
+ .split(/([-+(),*^/:?\s])/g)
249
+ .filter((el: string) => !/\s+/.test(el) && el !== "");
250
+
251
+ // Handling the special case of unary `-` and `+`.
252
+
253
+ let previousToken = "";
254
+ let carriedToken: string | null = null;
255
+ const parsedTokens: string[] = [];
256
+
257
+ for (const token of tokens) {
258
+ if (
259
+ (token == "+" || token == "-") &&
260
+ this.mathematicalOperators.has(previousToken)
261
+ ) {
262
+ carriedToken = token;
263
+ } else if (carriedToken) {
264
+ parsedTokens.push(carriedToken + token);
265
+ carriedToken = null;
266
+ } else {
267
+ parsedTokens.push(token);
268
+ }
269
+
270
+ previousToken = token;
271
+ }
272
+
273
+ /**
274
+ * Shunting Yard Algorithm (EW Dijkstra)
275
+ */
276
+
277
+ const operatorStack = new Stack<string>();
278
+ const outputQueue = new Queue<string>();
279
+
280
+ for (const token of parsedTokens) {
281
+ if (token == "(") {
282
+ operatorStack.push("(");
283
+ } else if (token == ")") {
284
+ while (operatorStack.top() != "(") {
285
+ outputQueue.enqueue(operatorStack.pop()!);
286
+ }
287
+
288
+ operatorStack.pop();
289
+ } else if (this.mathematicalOperators.has(token)) {
290
+ while (
291
+ this.mathematicalOperators.has(operatorStack.top()!) &&
292
+ this.operatorPrecedence[token] <=
293
+ this.operatorPrecedence[operatorStack.top()!]
294
+ ) {
295
+ outputQueue.enqueue(operatorStack.pop()!);
296
+ }
297
+
298
+ operatorStack.push(token);
299
+ } else if (!Number.isNaN(token) && token != "") {
300
+ outputQueue.enqueue(token);
301
+ }
302
+ }
303
+
304
+ while (operatorStack.top()) {
305
+ outputQueue.enqueue(operatorStack.pop()!);
306
+ }
307
+
308
+ return outputQueue;
309
+ }
310
+
311
+ addParentheses(formula: string): string | null {
312
+ const rpn = this.buildRPN(formula);
313
+
314
+ if (!rpn) {
315
+ return null;
316
+ }
317
+
318
+ const lexedRPN: string[] = [];
319
+
320
+ while (!rpn.isEmpty()) {
321
+ lexedRPN.push(rpn.dequeue()!);
322
+ }
323
+
324
+ // Stores the operators that we encounter in the RPN
325
+ let operatorStack = new Stack<string | null>();
326
+
327
+ // Stores the `results`, which are essentially individual groups
328
+ // of tokens showing a meaningful value.
329
+ let resultStack = new Stack<string>();
330
+
331
+ lexedRPN.forEach((symbol) => {
332
+ let parsedLeftExpression: string, parsedRightExpression: string;
333
+
334
+ // If we encounter a number or a variable in the RPN, it is itself
335
+ // a calculated entity (say a result in itself), needs no modification
336
+ // and can be directly put into the result stack.
337
+
338
+ if (
339
+ this.variables.has(symbol) ||
340
+ (!isNaN(parseFloat(symbol)) && isFinite(parseFloat(symbol)))
341
+ ) {
342
+ resultStack.push(symbol);
343
+ operatorStack.push(null);
344
+ }
345
+
346
+ // If it is not a number/variable then it is an operator. We will
347
+ // take out previous operators from the `operatorStack`, compare
348
+ // them with the current one, adds brackets accordingly to the `results`
349
+ // around it, and then finally add it to the `operatorStack` for
350
+ // future reference.
351
+ else if (Object.keys(this.operatorPrecedence).includes(symbol)) {
352
+ let [rightExpression, leftExpression, operatorA, operatorB] = [
353
+ resultStack.pop()!,
354
+ resultStack.pop()!,
355
+ operatorStack.pop()!,
356
+ operatorStack.pop()!,
357
+ ];
358
+
359
+ // The conditions that govern when to show a parenthesis.
360
+
361
+ if (
362
+ this.operatorPrecedence[operatorB] <=
363
+ this.operatorPrecedence[symbol] ||
364
+ (this.operatorPrecedence[operatorB] ===
365
+ this.operatorPrecedence[symbol] &&
366
+ ["/", "-"].includes(symbol))
367
+ ) {
368
+ parsedLeftExpression = `(${leftExpression})`;
369
+ } else {
370
+ parsedLeftExpression = leftExpression;
371
+ }
372
+
373
+ if (
374
+ this.operatorPrecedence[operatorA] <=
375
+ this.operatorPrecedence[symbol] ||
376
+ (this.operatorPrecedence[operatorA] ===
377
+ this.operatorPrecedence[symbol] &&
378
+ ["/", "-"].includes(symbol))
379
+ ) {
380
+ parsedRightExpression = `(${rightExpression})`;
381
+ } else {
382
+ parsedRightExpression = rightExpression;
383
+ }
384
+
385
+ // The bracket included expression is now itself a `result`
386
+
387
+ resultStack.push(
388
+ `${parsedLeftExpression} ${symbol} ${parsedRightExpression}`
389
+ );
390
+
391
+ operatorStack.push(symbol);
392
+ } else throw `${symbol} is not a recognized symbol`;
393
+ });
394
+
395
+ if (!resultStack.isEmpty()) {
396
+ return resultStack.pop()!;
397
+ } else throw `${lexedRPN} is not a correct RPN`;
398
+ }
399
+
400
+ calculate(formula: string): CalculateResult {
401
+ let rpn = this.buildRPN(formula);
402
+ let calculationResult: CalculateResult = {
403
+ result: undefined,
404
+ errorString: null,
405
+ };
406
+
407
+ if (!rpn) {
408
+ return calculationResult;
409
+ }
410
+
411
+ let calcStack = new Stack<Big>();
412
+
413
+ while (!rpn.isEmpty()) {
414
+ const frontItem = rpn.dequeue()!;
415
+
416
+ if (!this.mathematicalOperators.has(frontItem)) {
417
+ calcStack.push(
418
+ Big(
419
+ Number.parseFloat(
420
+ this.variables.get(frontItem)?.toString() ?? frontItem
421
+ )
422
+ )
423
+ );
424
+ } else {
425
+ let operator = frontItem;
426
+ let numB = calcStack.pop()!;
427
+ let numA = calcStack.pop()!;
428
+
429
+ try {
430
+ switch (operator) {
431
+ case "+":
432
+ calcStack.push(Big(numA).add(Big(numB)));
433
+ break;
434
+ case "-":
435
+ calcStack.push(Big(numA).sub(Big(numB)));
436
+ break;
437
+ case "*":
438
+ calcStack.push(Big(numA).mul(Big(numB)));
439
+ break;
440
+ case "/":
441
+ if (Big(numB).toNumber() == 0) {
442
+ calculationResult.errorString = "Division by zero encountered";
443
+ return calculationResult;
444
+ }
445
+
446
+ calcStack.push(Big(numA).div(Big(numB)));
447
+ break;
448
+
449
+ // Big.js doesn't support exponentiating a Big to a Big, which
450
+ // is obvious due to performance overheads. Use this case with care.
451
+
452
+ case "^":
453
+ calcStack.push(Big(numA).pow(Big(numB).toNumber()));
454
+ }
455
+ } catch (error: any) {
456
+ calculationResult.errorString = error;
457
+ return calculationResult;
458
+ }
459
+ }
460
+ }
461
+
462
+ calculationResult.result = calcStack.top()?.toNumber();
463
+ return calculationResult;
464
+ }
465
+ }
@@ -0,0 +1,106 @@
1
+ export class Recommender {
2
+ private _trie: TrieNode;
3
+ private _mininumSuggestionLength: number;
4
+
5
+ constructor(variables: Map<string, number>, minSuggestionLen: number) {
6
+ this._mininumSuggestionLength = minSuggestionLen > 0 ? minSuggestionLen : 1;
7
+ this._trie = new TrieNode();
8
+
9
+ for (let variable of variables) {
10
+ this.insert(variable[0]);
11
+ }
12
+ }
13
+
14
+ insert(
15
+ word: string,
16
+ position: number = -1,
17
+ node: TrieNode | undefined = undefined
18
+ ): void {
19
+ if (position == -1) {
20
+ this.insert(word, 0, this._trie);
21
+ return;
22
+ }
23
+
24
+ if (position == word.length) {
25
+ node?.addChild("\0");
26
+ return;
27
+ }
28
+
29
+ if (!node!.getChild(word[position])) {
30
+ node?.addChild(word[position]);
31
+ }
32
+
33
+ this.insert(word, position + 1, node!.getChild(word[position]));
34
+ }
35
+
36
+ getRecommendation(word: string): string[] | null {
37
+ if (word.length < this._mininumSuggestionLength) {
38
+ return null;
39
+ }
40
+
41
+ let recommendations: string[] = [];
42
+ let currentPosition = 0;
43
+ let currentNode: TrieNode | undefined = this._trie;
44
+
45
+ while (currentNode && currentPosition < word.length) {
46
+ currentNode = currentNode.getChild(word[currentPosition]);
47
+ currentPosition++;
48
+ }
49
+
50
+ if (!currentNode) {
51
+ return null;
52
+ }
53
+
54
+ this._traverseAndGet(recommendations, currentNode, word);
55
+
56
+ if (
57
+ recommendations.length == 0 ||
58
+ (recommendations.length == 1 && recommendations[0] == word)
59
+ ) {
60
+ return null;
61
+ }
62
+
63
+ return recommendations;
64
+ }
65
+
66
+ private _traverseAndGet(
67
+ recommendations: string[],
68
+ node: TrieNode,
69
+ word: string,
70
+ currentString: string = ""
71
+ ) {
72
+ for (let child of node.children) {
73
+ if (child[0] == "\0") {
74
+ recommendations.push(word + currentString);
75
+ // return;
76
+ }
77
+
78
+ this._traverseAndGet(
79
+ recommendations,
80
+ child[1],
81
+ word,
82
+ currentString + child[0]
83
+ );
84
+ }
85
+ }
86
+ }
87
+
88
+ class TrieNode {
89
+ constructor() {
90
+ this._children = new Map<string, TrieNode>();
91
+ }
92
+
93
+ private _children: Map<string, TrieNode>;
94
+
95
+ get children() {
96
+ return this._children;
97
+ }
98
+
99
+ getChild(char: string) {
100
+ return this._children.get(char);
101
+ }
102
+
103
+ addChild(char: string) {
104
+ this._children.set(char, new TrieNode());
105
+ }
106
+ }
@@ -0,0 +1,46 @@
1
+ import { css } from "lit";
2
+
3
+ export const FormulaEditorStyles = css`
4
+ #wysiwyg-editor {
5
+ display: inline-block;
6
+ border: none;
7
+ padding: 4px;
8
+ caret-color: var(--fe-caret-color, #fff);
9
+ color: var(--fe-text-color, #f7f1ff);
10
+ line-height: 1.1;
11
+ width: 100%;
12
+ height: var(--fe-height, 60%);
13
+ border-radius: var(--fe-border-radius, 4px) var(--fe-border-radius, 4px) 0px
14
+ 0px;
15
+ overflow: auto;
16
+ border: 2px solid black;
17
+ outline: 0px solid black;
18
+ white-space: pre-wrap;
19
+ background-color: var(--fe-background-color, #222222);
20
+ margin: 0px;
21
+ /* position: relative; */
22
+ }
23
+
24
+ .wysiwygInternals.error {
25
+ text-decoration: underline;
26
+ -webkit-text-decoration-color: var(--fe-err-underline-color, #fc514f);
27
+ text-decoration-color: var(--fe-err-underline-color, #fc514f);
28
+ -webkit-text-decoration-style: wavy;
29
+ text-decoration-style: wavy;
30
+ /* text-decoration-thickness: 1px; */
31
+ text-decoration-color: var(--fe-err-underline-color, red);
32
+ }
33
+
34
+ .wysiwygInternals.bracket {
35
+ color: var(--fe-bracket-color, #fc514f);
36
+ }
37
+
38
+ .wysiwygInternals.operator {
39
+ font-weight: bold;
40
+ color: var(--fe-operator-color, #fc618d);
41
+ }
42
+
43
+ .wysiwygInternals.variable {
44
+ color: var(--fe-variable-color, #fc618d);
45
+ }
46
+ `;
@@ -0,0 +1,13 @@
1
+ import { LitElement, html } from "lit";
2
+ import { customElement, property } from "lit/decorators.js";
3
+ import { Operator } from "../helpers/types";
4
+
5
+ @customElement("operator-input")
6
+ class OperatorInput extends LitElement {
7
+ @property()
8
+ operator: Operator = Operator.NONE;
9
+
10
+ render() {
11
+ return html`<span> ${this.operator} </span>`;
12
+ }
13
+ }