@fw-components/formula-editor 2.0.3-formula-editor.1 → 2.0.7-formula-editor.0

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,357 @@
1
+ import Big from "big.js";
2
+ import { Expectation, Queue, Stack } from "./helpers.js";
3
+ import { Recommender } from "./recommendor.js";
4
+ export class Parser {
5
+ constructor(variables, minSuggestionLen) {
6
+ this.mathematicalOperators = new Set(["^", "+", "-", "*", "/"]);
7
+ this.operatorPrecedence = {
8
+ "^": 3,
9
+ "/": 2,
10
+ "*": 2,
11
+ "+": 1,
12
+ "-": 1,
13
+ };
14
+ this.variables = variables;
15
+ this._recommender = new Recommender(this.variables, minSuggestionLen);
16
+ }
17
+ parseInput(formula, prevCurPos = null, recommendation = null) {
18
+ let tokens = formula.split(/([-+(),*^/:?\s])/g);
19
+ // Stores the positions of opening parentheses. This allows us to
20
+ // show "Unclosed parenthesis error" for positions which are far behind
21
+ // our current token
22
+ let parentheses = new Stack();
23
+ // The HTML formatted string which we eventually show on the view.
24
+ let formattedString = ``;
25
+ // The expectation that we have for the current token.
26
+ let expectation = Expectation.VARIABLE;
27
+ // Position of the current token in the formula string.
28
+ let currentPosition = 0;
29
+ // Previous 'token' (not a space or a new line) that we just encountered.
30
+ let previousToken = "";
31
+ // The object that we return as the output of the parsing result.
32
+ let parseOutput = {
33
+ recommendations: null,
34
+ formattedContent: null,
35
+ formattedString: null,
36
+ newCursorPosition: prevCurPos ?? -1,
37
+ errorString: null,
38
+ };
39
+ console.log(tokens);
40
+ tokens.forEach((token) => {
41
+ // It is a number is either it's in the defined variables, or
42
+ // it's a valid number literal.
43
+ let isNumber = this.variables.has(token) || !Number.isNaN(Number(token)), isOperator = this.mathematicalOperators.has(token), isSpace = token.trim() == "", isBracket = token == "(" || token == ")";
44
+ // We don't really want anything for the spaces, other than simply
45
+ // adding them back to the view.
46
+ if (isSpace) {
47
+ formattedString = `${formattedString}${token}`;
48
+ currentPosition += token.length;
49
+ return;
50
+ }
51
+ // If the cursor position is 'inside` the current token:
52
+ //
53
+ // 1. If we've got a recommendation to add, simply replace the
54
+ // word with the recommendation.
55
+ // 2. Ask the recommendor to fetch recommendations for this specific
56
+ // token/word.
57
+ if (currentPosition <= prevCurPos &&
58
+ currentPosition + token.length >= prevCurPos) {
59
+ // If a recommendation was provided, replace the correspoding
60
+ // word with it and move the cursor forward, accordingly.
61
+ if (recommendation) {
62
+ // Since we are sure that the recommendation will always correspond
63
+ // to a variable.
64
+ isNumber = true;
65
+ // If the new cursor length somehow becomes larger than the
66
+ // length of the formula string, setting the caret to that
67
+ // length will move the caret to the start. Although this overflow
68
+ // won't happen, but still, this check prevents that.
69
+ parseOutput.newCursorPosition = Math.min(parseOutput.newCursorPosition +
70
+ recommendation.length -
71
+ token.length, formula.length + recommendation.length - token.length);
72
+ token = recommendation;
73
+ recommendation = null;
74
+ }
75
+ // Fetch recommendations nonetheless.
76
+ parseOutput.recommendations =
77
+ this._recommender.getRecommendation(token);
78
+ console.log(parseOutput.recommendations);
79
+ }
80
+ let tokenClassName = "";
81
+ // Don't check for errors if an error has already been encountered.
82
+ if (expectation != Expectation.UNDEFINED) {
83
+ // Unnecessary closing parenthesis
84
+ if (parentheses.isEmpty() && token == ")") {
85
+ parseOutput.errorString = `Unexpected ')' at pos: ${currentPosition}`;
86
+ tokenClassName += " error";
87
+ expectation = Expectation.UNDEFINED;
88
+ }
89
+ // Operator or ) after an operator. Eg: `23 / *` or `23 / )`
90
+ // Unary `+` and `-` are not an error as they might represent
91
+ // a positive or negative number respectively. But they will still
92
+ // be an error if the formula ends with them.
93
+ else if (expectation == Expectation.VARIABLE &&
94
+ !isNumber &&
95
+ token != "(" &&
96
+ !((token == "-" || token == "+") &&
97
+ this.mathematicalOperators.has(previousToken))) {
98
+ parseOutput.errorString = `Expected variable/number at pos: ${currentPosition}`;
99
+ tokenClassName += " error";
100
+ expectation = Expectation.UNDEFINED;
101
+ }
102
+ // Number/Variable after the same. Eg: `a a` or `420 420`.
103
+ // Having a ) is fine. Eg: `23)` might be representing `(23 + 23)
104
+ else if (expectation == Expectation.OPERATOR &&
105
+ !isOperator &&
106
+ token != ")") {
107
+ parseOutput.errorString = `Expected mathematical operator at pos: ${currentPosition}`;
108
+ tokenClassName += " error";
109
+ expectation = Expectation.UNDEFINED;
110
+ }
111
+ // Unknown symbol/variable/word
112
+ else if (!(isNumber || isOperator || isBracket)) {
113
+ parseOutput.errorString = `Unknown word at pos: ${currentPosition}`;
114
+ tokenClassName += " error";
115
+ expectation = Expectation.UNDEFINED;
116
+ }
117
+ // The case of division by zero. Since we can't know if an expression evaluates
118
+ // to zero or not, that case can only be handled during calculation.
119
+ else if (isNumber &&
120
+ previousToken == "/" &&
121
+ (this.variables.get(token) == 0 || Number(token) == 0)) {
122
+ parseOutput.errorString = `Division by zero at pos: ${currentPosition}`;
123
+ tokenClassName += " error";
124
+ expectation = Expectation.UNDEFINED;
125
+ }
126
+ // Empty brackets. Default might be takn as 0, but that will only make sense
127
+ // in addition and subtraction and not in other operators, so making this
128
+ // case an error makes more sense.
129
+ else if (previousToken == "(" && token == ")") {
130
+ parseOutput.errorString = `Empty brackets at position ${currentPosition}`;
131
+ tokenClassName += " error";
132
+ expectation = Expectation.UNDEFINED;
133
+ }
134
+ }
135
+ // Setting the expectation for the next token, if we have not encountered an
136
+ // error already.
137
+ if (expectation != Expectation.UNDEFINED) {
138
+ if (token == "(" || isOperator) {
139
+ expectation = Expectation.VARIABLE;
140
+ }
141
+ else if (token == ")" || isNumber) {
142
+ expectation = Expectation.OPERATOR;
143
+ }
144
+ }
145
+ if (token == "(") {
146
+ parentheses.push(currentPosition);
147
+ tokenClassName += " bracket";
148
+ }
149
+ else if (token == ")") {
150
+ parentheses.pop();
151
+ tokenClassName += " bracket";
152
+ }
153
+ else if (isOperator) {
154
+ tokenClassName += " operator";
155
+ }
156
+ else if (expectation == Expectation.UNDEFINED) {
157
+ tokenClassName += " error";
158
+ }
159
+ // Since not using ShadowDOM, having these specific class names will prevent
160
+ // name collision.
161
+ formattedString = `${formattedString}<span class="wysiwygInternals ${tokenClassName}">${token}</span>`;
162
+ currentPosition += token.length;
163
+ previousToken = token;
164
+ });
165
+ // If the formula ends with a mathematical operator, or has unclosed `(`
166
+ if (this.mathematicalOperators.has(previousToken)) {
167
+ parseOutput.errorString = "Unexpected ending of formula.";
168
+ }
169
+ else if (!parentheses.isEmpty()) {
170
+ parseOutput.errorString = `Unclosed '(' at position: ${parentheses.top()}`;
171
+ }
172
+ const parser = new DOMParser();
173
+ const doc = parser.parseFromString(formattedString, "text/html");
174
+ parseOutput.formattedContent = doc.querySelector("body");
175
+ parseOutput.formattedString = formattedString;
176
+ return parseOutput;
177
+ }
178
+ buildRPN(formula) {
179
+ if (this.parseInput(formula).errorString) {
180
+ return null;
181
+ }
182
+ const tokens = formula
183
+ .split(/([-+(),*^/:?\s])/g)
184
+ .filter((el) => !/\s+/.test(el) && el !== "");
185
+ // Handling the special case of unary `-` and `+`.
186
+ let previousToken = "";
187
+ let carriedToken = null;
188
+ const parsedTokens = [];
189
+ for (const token of tokens) {
190
+ if ((token == "+" || token == "-") &&
191
+ this.mathematicalOperators.has(previousToken)) {
192
+ carriedToken = token;
193
+ }
194
+ else if (carriedToken) {
195
+ parsedTokens.push(carriedToken + token);
196
+ carriedToken = null;
197
+ }
198
+ else {
199
+ parsedTokens.push(token);
200
+ }
201
+ previousToken = token;
202
+ }
203
+ /**
204
+ * Shunting Yard Algorithm (EW Dijkstra)
205
+ */
206
+ const operatorStack = new Stack();
207
+ const outputQueue = new Queue();
208
+ for (const token of parsedTokens) {
209
+ if (token == "(") {
210
+ operatorStack.push("(");
211
+ }
212
+ else if (token == ")") {
213
+ while (operatorStack.top() != "(") {
214
+ outputQueue.enqueue(operatorStack.pop());
215
+ }
216
+ operatorStack.pop();
217
+ }
218
+ else if (this.mathematicalOperators.has(token)) {
219
+ while (this.mathematicalOperators.has(operatorStack.top()) &&
220
+ this.operatorPrecedence[token] <=
221
+ this.operatorPrecedence[operatorStack.top()]) {
222
+ outputQueue.enqueue(operatorStack.pop());
223
+ }
224
+ operatorStack.push(token);
225
+ }
226
+ else if (!Number.isNaN(token) && token != "") {
227
+ outputQueue.enqueue(token);
228
+ }
229
+ }
230
+ while (operatorStack.top()) {
231
+ outputQueue.enqueue(operatorStack.pop());
232
+ }
233
+ return outputQueue;
234
+ }
235
+ addParentheses(formula) {
236
+ const rpn = this.buildRPN(formula);
237
+ if (!rpn) {
238
+ return null;
239
+ }
240
+ const lexedRPN = [];
241
+ while (!rpn.isEmpty()) {
242
+ lexedRPN.push(rpn.dequeue());
243
+ }
244
+ // Stores the operators that we encounter in the RPN
245
+ let operatorStack = new Stack();
246
+ // Stores the `results`, which are essentially individual groups
247
+ // of tokens showing a meaningful value.
248
+ let resultStack = new Stack();
249
+ lexedRPN.forEach((symbol) => {
250
+ let parsedLeftExpression, parsedRightExpression;
251
+ // If we encounter a number or a variable in the RPN, it is itself
252
+ // a calculated entity (say a result in itself), needs no modification
253
+ // and can be directly put into the result stack.
254
+ if (this.variables.has(symbol) ||
255
+ (!isNaN(parseFloat(symbol)) && isFinite(parseFloat(symbol)))) {
256
+ resultStack.push(symbol);
257
+ operatorStack.push(null);
258
+ }
259
+ // If it is not a number/variable then it is an operator. We will
260
+ // take out previous operators from the `operatorStack`, compare
261
+ // them with the current one, adds brackets accordingly to the `results`
262
+ // around it, and then finally add it to the `operatorStack` for
263
+ // future reference.
264
+ else if (Object.keys(this.operatorPrecedence).includes(symbol)) {
265
+ let [rightExpression, leftExpression, operatorA, operatorB] = [
266
+ resultStack.pop(),
267
+ resultStack.pop(),
268
+ operatorStack.pop(),
269
+ operatorStack.pop(),
270
+ ];
271
+ // The conditions that govern when to show a parenthesis.
272
+ if (this.operatorPrecedence[operatorB] <=
273
+ this.operatorPrecedence[symbol] ||
274
+ (this.operatorPrecedence[operatorB] ===
275
+ this.operatorPrecedence[symbol] &&
276
+ ["/", "-"].includes(symbol))) {
277
+ parsedLeftExpression = `(${leftExpression})`;
278
+ }
279
+ else {
280
+ parsedLeftExpression = leftExpression;
281
+ }
282
+ if (this.operatorPrecedence[operatorA] <=
283
+ this.operatorPrecedence[symbol] ||
284
+ (this.operatorPrecedence[operatorA] ===
285
+ this.operatorPrecedence[symbol] &&
286
+ ["/", "-"].includes(symbol))) {
287
+ parsedRightExpression = `(${rightExpression})`;
288
+ }
289
+ else {
290
+ parsedRightExpression = rightExpression;
291
+ }
292
+ // The bracket included expression is now itself a `result`
293
+ resultStack.push(`${parsedLeftExpression} ${symbol} ${parsedRightExpression}`);
294
+ operatorStack.push(symbol);
295
+ }
296
+ else
297
+ throw `${symbol} is not a recognized symbol`;
298
+ });
299
+ if (!resultStack.isEmpty()) {
300
+ return resultStack.pop();
301
+ }
302
+ else
303
+ throw `${lexedRPN} is not a correct RPN`;
304
+ }
305
+ calculate(formula) {
306
+ let rpn = this.buildRPN(formula);
307
+ let calculationResult = {
308
+ result: undefined,
309
+ errorString: null,
310
+ };
311
+ if (!rpn) {
312
+ return calculationResult;
313
+ }
314
+ let calcStack = new Stack();
315
+ while (!rpn.isEmpty()) {
316
+ const frontItem = rpn.dequeue();
317
+ if (!this.mathematicalOperators.has(frontItem)) {
318
+ calcStack.push(Big(Number.parseFloat(this.variables.get(frontItem)?.toString() ?? frontItem)));
319
+ }
320
+ else {
321
+ let operator = frontItem;
322
+ let numB = calcStack.pop();
323
+ let numA = calcStack.pop();
324
+ try {
325
+ switch (operator) {
326
+ case "+":
327
+ calcStack.push(Big(numA).add(Big(numB)));
328
+ break;
329
+ case "-":
330
+ calcStack.push(Big(numA).sub(Big(numB)));
331
+ break;
332
+ case "*":
333
+ calcStack.push(Big(numA).mul(Big(numB)));
334
+ break;
335
+ case "/":
336
+ if (parseFloat(Big(numB).toString()) == 0) {
337
+ calculationResult.errorString = "Division by zero encountered";
338
+ return calculationResult;
339
+ }
340
+ calcStack.push(Big(numA).div(Big(numB)));
341
+ break;
342
+ // Big.js doesn't support exponentiating a Big to a Big, which
343
+ // is obvious due to performance overheads. Use this case with care.
344
+ case "^":
345
+ calcStack.push(Big(numA).pow(parseFloat(Big(numB).toString())));
346
+ }
347
+ }
348
+ catch (error) {
349
+ calculationResult.errorString = error;
350
+ return calculationResult;
351
+ }
352
+ }
353
+ }
354
+ calculationResult.result = parseFloat(calcStack.top().toString());
355
+ return calculationResult;
356
+ }
357
+ }
@@ -0,0 +1,67 @@
1
+ export class Recommender {
2
+ constructor(variables, minSuggestionLen) {
3
+ this._mininumSuggestionLength = minSuggestionLen > 0 ? minSuggestionLen : 1;
4
+ this._trie = new TrieNode();
5
+ for (let variable of variables) {
6
+ this.insert(variable[0]);
7
+ }
8
+ }
9
+ insert(word, position = -1, node = undefined) {
10
+ if (position == -1) {
11
+ this.insert(word, 0, this._trie);
12
+ return;
13
+ }
14
+ if (position == word.length) {
15
+ node?.addChild("\0");
16
+ return;
17
+ }
18
+ if (!node.getChild(word[position])) {
19
+ node?.addChild(word[position]);
20
+ }
21
+ this.insert(word, position + 1, node.getChild(word[position]));
22
+ }
23
+ getRecommendation(word) {
24
+ if (word.length < this._mininumSuggestionLength) {
25
+ return null;
26
+ }
27
+ let recommendations = [];
28
+ let currentPosition = 0;
29
+ let currentNode = this._trie;
30
+ while (currentNode && currentPosition < word.length) {
31
+ currentNode = currentNode.getChild(word[currentPosition]);
32
+ currentPosition++;
33
+ }
34
+ if (!currentNode) {
35
+ return null;
36
+ }
37
+ this._traverseAndGet(recommendations, currentNode, word);
38
+ if (recommendations.length == 0 ||
39
+ (recommendations.length == 1 && recommendations[0] == word)) {
40
+ return null;
41
+ }
42
+ return recommendations;
43
+ }
44
+ _traverseAndGet(recommendations, node, word, currentString = "") {
45
+ for (let child of node.children) {
46
+ if (child[0] == "\0") {
47
+ recommendations.push(word + currentString);
48
+ // return;
49
+ }
50
+ this._traverseAndGet(recommendations, child[1], word, currentString + child[0]);
51
+ }
52
+ }
53
+ }
54
+ class TrieNode {
55
+ constructor() {
56
+ this._children = new Map();
57
+ }
58
+ get children() {
59
+ return this._children;
60
+ }
61
+ getChild(char) {
62
+ return this._children.get(char);
63
+ }
64
+ addChild(char) {
65
+ this._children.set(char, new TrieNode());
66
+ }
67
+ }
@@ -1,6 +1,5 @@
1
1
  import { css } from "lit";
2
-
3
- export const FormulaEditorStyles = css`
2
+ export const FormulaEditorStyles = css `
4
3
  #wysiwyg-editor {
5
4
  display: inline-block;
6
5
  border: none;
@@ -0,0 +1,24 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { LitElement, html } from "lit";
8
+ import { customElement, property } from "lit/decorators.js";
9
+ import { Operator } from "../helpers/types";
10
+ let OperatorInput = class OperatorInput extends LitElement {
11
+ constructor() {
12
+ super(...arguments);
13
+ this.operator = Operator.NONE;
14
+ }
15
+ render() {
16
+ return html `<span> ${this.operator} </span>`;
17
+ }
18
+ };
19
+ __decorate([
20
+ property()
21
+ ], OperatorInput.prototype, "operator", void 0);
22
+ OperatorInput = __decorate([
23
+ customElement("operator-input")
24
+ ], OperatorInput);
@@ -0,0 +1,82 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { css, html, LitElement } from "lit";
8
+ import { customElement, property } from "lit/decorators.js";
9
+ let SuggestionMenu = class SuggestionMenu extends LitElement {
10
+ constructor() {
11
+ super(...arguments);
12
+ this.recommendations = [];
13
+ this.onClickRecommendation = (recommendation) => { };
14
+ this.currentSelection = "";
15
+ }
16
+ static { this.styles = css `
17
+ ul {
18
+ border: 1px solid var(--fe-suggestion-color, white);
19
+ color: var(--fe-suggestion-color, #bab6c0);
20
+ background-color: var(--fe-suggestion-background-color, #363537);
21
+ box-sizing: border-box;
22
+ width: fit-content;
23
+ list-style-type: none;
24
+ padding: 4px 0px;
25
+ margin: 2px;
26
+ }
27
+
28
+ li {
29
+ margin: 0px;
30
+ padding: 2px 6px;
31
+ cursor: pointer;
32
+ }
33
+
34
+ li.selected {
35
+ background-color: var(--fe-suggestion-selected-background-color, darkgrey);
36
+ color: var(--fe-suggestion-selected-color, yellow);
37
+ }
38
+
39
+ li:focus-visible {
40
+ /* outline: 1px solid red; */
41
+ outline: 0px;
42
+ color: var(--fe-suggestion-focus-color, #fce566);
43
+ background-color: var(--fe-suggestion-focus-background-color, #69676c);
44
+ }
45
+ `; }
46
+ handleKeydown(event, recommendation) {
47
+ if (event.code == "Enter") {
48
+ event.preventDefault();
49
+ event.stopPropagation();
50
+ this.onClickRecommendation(recommendation);
51
+ }
52
+ }
53
+ render() {
54
+ return html `
55
+ <ul class="wysiwyg-suggestion-menu">
56
+ ${this.recommendations.map((recommendation) => {
57
+ return html `<li
58
+ class="${this.currentSelection === recommendation ? 'selected' : ''}"
59
+ tabindex="0"
60
+ @click=${(e) => this.onClickRecommendation(recommendation)}
61
+ @keydown=${(e) => this.handleKeydown(e, recommendation)}
62
+ >
63
+ ${recommendation}
64
+ </li>`;
65
+ })}
66
+ </ul>
67
+ `;
68
+ }
69
+ };
70
+ __decorate([
71
+ property()
72
+ ], SuggestionMenu.prototype, "recommendations", void 0);
73
+ __decorate([
74
+ property()
75
+ ], SuggestionMenu.prototype, "onClickRecommendation", void 0);
76
+ __decorate([
77
+ property()
78
+ ], SuggestionMenu.prototype, "currentSelection", void 0);
79
+ SuggestionMenu = __decorate([
80
+ customElement("suggestion-menu")
81
+ ], SuggestionMenu);
82
+ export { SuggestionMenu };