@cocreate/calculate 1.15.0 → 1.16.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.16.0](https://github.com/CoCreate-app/CoCreate-calculate/compare/v1.15.0...v1.16.0) (2025-04-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * sanitize and calulate expressionbs ([0710efb](https://github.com/CoCreate-app/CoCreate-calculate/commit/0710efbdd72bdc563e387819e5a7c43a9d5bfb25))
7
+ * update demo ([4d6f6b6](https://github.com/CoCreate-app/CoCreate-calculate/commit/4d6f6b60b68dcdefb1eae64939fde8eea0e45096))
8
+ * update observer observe param to type and and attributeName to attributeFilter ([d50f013](https://github.com/CoCreate-app/CoCreate-calculate/commit/d50f013c0fb3de9e94bad052e11077da042ae223))
9
+
10
+
11
+ ### Features
12
+
13
+ * **calculator:** Add mathematical expression evaluator ([89a3e61](https://github.com/CoCreate-app/CoCreate-calculate/commit/89a3e61c87b7cbcdd42a29583ddfbceafe611df8))
14
+
1
15
  # [1.15.0](https://github.com/CoCreate-app/CoCreate-calculate/compare/v1.14.7...v1.15.0) (2024-11-04)
2
16
 
3
17
 
package/demo/index.html CHANGED
@@ -1,30 +1,25 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
- <head>
4
- <title>Calculate | CoCreateJS</title>
5
- <!-- CoCreate Favicon -->
6
- <link
7
- rel="icon"
8
- type="image/png"
9
- sizes="32x32"
10
- href="../assets/favicon.ico" />
11
- <link rel="manifest" href="/manifest.webmanifest" />
12
- </head>
13
- <body>
14
- <input class="we" key="total" id="id1" value="12" />
15
- <input class="we" key="total" id="id2" value="13" />
16
- <!--<input id="id3" value="14"><br><br><br>-->
3
+ <head>
4
+ <title>Calculate | CoCreateJS</title>
5
+ <!-- CoCreate Favicon -->
6
+ <link
7
+ rel="icon"
8
+ type="image/png"
9
+ sizes="32x32"
10
+ href="../assets/favicon.ico" />
11
+ <link rel="manifest" href="/manifest.webmanifest" />
12
+ </head>
13
+ <body>
14
+ <input value="12" calculate="$value * 100" />
15
+ <input class="class1" key="total" id="id1" value="12" />
16
+ <input class="class1" key="total" id="id2" value="13" />
17
17
 
18
- <input id="te" calculate="{(#id1)} + {(#id2)}" />
19
- <!--<input calculate="1 + 3 * 5">-->
20
- <input calculate="{[key='total']} + 1" />
21
- <h1 calculate="{[key='total']} + {(#te)}">sum</h1>
18
+ <input id="te" calculate="($selctor #id1) + ($selctor #id2)" />
19
+ <input calculate="($selctor [key='total']) + 1" />
20
+ <h1 calculate="($selctor [key='total']) + ($selctor .class1)">sum</h1>
22
21
 
23
- <!--<h1 calculate="sum[.we]">sum</h1>-->
24
- <!--<h1 calculate="1 + {(#id1)} * 5 + {(#id3)}/{(#id2)} + {(#id2)}"></h1>-->
25
- <!--<h1 calculate="(({#id1} + {#id2})) * {#id3}"></h1>-->
26
-
27
- <!--<script src="../dist/CoCreate-calculate.js"></script>-->
28
- <script src="https://CoCreate.app/dist/CoCreate.js"></script>
29
- </body>
22
+ <!--<script src="../dist/CoCreate-calculate.js"></script>-->
23
+ <script src="https://dev.CoCreate.app/dist/CoCreate.js"></script>
24
+ </body>
30
25
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocreate/calculate",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "A handy vanilla JavaScript calculator, concatenate multiple elements containing integers & execute calculates. Can be used for creating invoices,making payments & any kind of complex calculate. Easily configured using HTML5 attributes and/or JavaScript API.",
5
5
  "keywords": [
6
6
  "calculate",
package/src/index.js CHANGED
@@ -1,166 +1,945 @@
1
- import observer from '@cocreate/observer';
2
- import { getAttributes } from '@cocreate/utils';
1
+ import observer from "@cocreate/observer";
2
+ import { queryElements } from "@cocreate/utils";
3
3
  // import { renderValue } from '@cocreate/render';
4
- import '@cocreate/element-prototype';
5
-
4
+ import "@cocreate/element-prototype";
6
5
 
7
6
  function init() {
8
- let calculateElements = document.querySelectorAll('[calculate]');
9
- initElements(calculateElements);
7
+ let calculateElements = document.querySelectorAll("[calculate]");
8
+ initElements(calculateElements);
10
9
  }
11
10
 
12
11
  function initElements(elements) {
13
- for (let el of elements)
14
- initElement(el);
12
+ for (let el of elements) initElement(el);
15
13
  }
16
14
 
17
- function initElement(element) {
18
- let calculate = element.getAttribute('calculate');
19
- if (calculate.includes('{{') || calculate.includes('{['))
20
- return;
21
-
22
- let selectors = getSelectors(calculate);
23
-
24
- for (let i = 0; i < selectors.length; i++) {
25
- // if (selectors[i].includes('{{')) return;
26
-
27
- // initEvents(element, selectors[i]);
28
- let inputs = document.querySelectorAll(selectors[i]);
29
- for (let input of inputs) {
30
- initEvent(element, input);
31
- }
32
-
33
- observer.init({
34
- name: 'calculateSelectorInit',
35
- observe: ['addedNodes'],
36
- selector: selectors[i],
37
- callback(mutation) {
38
- initEvent(element, mutation.target);
39
- setCalcationResult(element);
40
- }
41
- });
42
- }
43
- setCalcationResult(element);
15
+ async function initElement(element) {
16
+ let calculate = element.getAttribute("calculate");
17
+ if (calculate.includes("{{") || calculate.includes("{[")) return;
18
+
19
+ let selectors = getSelectors(calculate);
20
+
21
+ for (let i = 0; i < selectors.length; i++) {
22
+ let inputs = queryElements({
23
+ element,
24
+ selector: selectors[i],
25
+ type: "selector"
26
+ });
27
+
28
+ for (let input of inputs) {
29
+ initEvent(element, input);
30
+ }
31
+
32
+ observer.init({
33
+ name: "calculateSelectorInit",
34
+ types: ["addedNodes"],
35
+ selector: selectors[i],
36
+ callback(mutation) {
37
+ initEvent(element, mutation.target);
38
+ setCalcationValue(element);
39
+ }
40
+ });
41
+ }
42
+ setCalcationValue(element);
44
43
  }
45
44
 
45
+ /**
46
+ * Extracts selector strings starting with '$' from within parentheses in a given string.
47
+ * Ensures that the keyword (selector, closest, etc.) is followed by a word boundary.
48
+ * Returns an array of unique matching selector strings.
49
+ *
50
+ * @param {string} string The input string to search.
51
+ * @returns {string[]} An array of unique matching selector strings found.
52
+ */
46
53
  function getSelectors(string) {
47
- let regex = /\{\((?:(?!\{\().)*?\)\}/;
48
- let selectors = [];
49
-
50
- let match;
51
- while ((match = regex.exec(string)) !== null) {
52
- // Extract the content inside the braces (excluding the leading '{(' and trailing ')}')
53
- let selector = match[0].slice(2, -2);
54
-
55
- if (selectors.indexOf(selector) === -1) {
56
- selectors.push(selector);
57
- }
58
-
59
- // Replace the found match with an empty string to avoid reprocessing in the next iteration
60
- string = string.replace(match[0], '');
61
- }
62
-
63
- return selectors;
54
+ if (!string) {
55
+ return []; // Return an empty array if input is null, undefined, or an empty string
56
+ }
57
+
58
+ // Regex provided by user: Finds parentheses, allows optional space,
59
+ // captures from '$' + keyword + word boundary + rest until ')'
60
+ const selectorRegex =
61
+ /\(\s*(\$(?:selector|closest|parent|next|previous|document|frame|top)\b[^)]*)\)/g;
62
+
63
+ const uniqueMatches = new Set();
64
+ let match;
65
+
66
+ // Use regex.exec() in a loop to find all matches
67
+ while ((match = selectorRegex.exec(string)) !== null) {
68
+ // match[1] contains the captured group (e.g., "$selector .button")
69
+ // Add the trimmed match to the Set. Duplicates are automatically ignored.
70
+ uniqueMatches.add(match[1].trim());
71
+
72
+ // Handle potential edge case with zero-length matches to prevent infinite loops
73
+ // Although less likely with this specific regex, it's good practice
74
+ if (match.index === selectorRegex.lastIndex) {
75
+ selectorRegex.lastIndex++;
76
+ }
77
+ }
78
+
79
+ return Array.from(uniqueMatches);
64
80
  }
65
81
 
66
- async function getValues(calculate) {
67
- let selectors = getSelectors(calculate);
68
-
69
- for (let i = 0; i < selectors.length; i++) {
70
- let selector = selectors[i];
71
-
72
- let value = null;
73
- let inputs = document.querySelectorAll(selector);
74
-
75
- for (let input of inputs) {
76
- let val = null;
77
- if (input.getValue)
78
- val = Number(await input.getValue());
82
+ // Map: Key = InputElement, Value = Array of Elements to update
83
+ const initializedInputs = new Map();
84
+
85
+ /**
86
+ * Associates an element to be updated with a specific input element.
87
+ * Attaches an 'input' event listener to the input element only once.
88
+ * When the input event fires, calls setCalcationValue for all associated elements.
89
+ *
90
+ * @param {HTMLElement} element The element that needs to be updated.
91
+ * @param {HTMLInputElement} input The input element that triggers the update.
92
+ */
93
+ function initEvent(element, input) {
94
+ const calculteElements = initializedInputs.get(input);
95
+ if (calculteElements) {
96
+ calculteElements.add(element);
97
+ return;
98
+ }
99
+
100
+ initializedInputs.set(input, new Set([element]));
101
+
102
+ input.addEventListener("input", function () {
103
+ const elementsToUpdate = initializedInputs.get(input);
104
+
105
+ if (elementsToUpdate) {
106
+ for (const element of elementsToUpdate) {
107
+ setCalcationValue(element);
108
+ }
109
+ }
110
+ });
111
+ }
79
112
 
80
- if (!Number.isNaN(value)) {
81
- value += val;
82
- }
83
- }
113
+ async function setCalcationValue(element) {
114
+ let calString = await getValues(element);
115
+ element.setValue(calculate(calString));
116
+ }
84
117
 
85
- if (value != null && !Number.isNaN(value)) {
86
- calculate = calculate.replaceAll('{(' + selector + ')}', value);
87
- }
88
- }
118
+ async function getValues(element) {
119
+ let calculate = element.getAttribute("calculate");
120
+
121
+ let selectors = getSelectors(calculate);
122
+
123
+ for (let i = 0; i < selectors.length; i++) {
124
+ let value = 0; // Default to 0 for missing inputs
125
+ let inputs = queryElements({
126
+ // Ensure queryElements can be awaited if needed
127
+ element,
128
+ selector: selectors[i],
129
+ type: "selector"
130
+ });
131
+
132
+ for (let input of inputs) {
133
+ initEvent(element, input);
134
+ let val = null;
135
+ if (input.getValue) {
136
+ val = Number(await input.getValue());
137
+ }
138
+
139
+ if (!Number.isNaN(val)) {
140
+ value += val; // Accumulate valid numeric values
141
+ } else {
142
+ console.warn(
143
+ `Invalid value for selector "${selectors[i]}". Defaulting to 0.`
144
+ );
145
+ }
146
+ }
147
+
148
+ calculate = calculate.replaceAll(`(${selectors[i]})`, value);
149
+ }
150
+
151
+ return calculate;
152
+ }
89
153
 
90
- return calculate;
154
+ // Defines mathematical constants available in expressions.
155
+ const constants = { PI: Math.PI, E: Math.E };
156
+
157
+ // Defines allowed mathematical functions and maps them to their JavaScript Math counterparts.
158
+ const functions = {
159
+ abs: Math.abs,
160
+ ceil: Math.ceil,
161
+ floor: Math.floor,
162
+ round: Math.round,
163
+ max: Math.max, // Note: RPN evaluator assumes arity 2 for max/min
164
+ min: Math.min, // Note: RPN evaluator assumes arity 2 for max/min
165
+ pow: Math.pow,
166
+ sqrt: Math.sqrt,
167
+ log: Math.log, // Natural logarithm
168
+ sin: Math.sin,
169
+ cos: Math.cos,
170
+ tan: Math.tan
171
+ };
172
+
173
+ /**
174
+ * Tokenizer for Core Mathematical Expressions.
175
+ * Converts a mathematical expression string (without ternary operators) into an array of tokens.
176
+ * Each token is an object with 'type' and 'value'.
177
+ * Supported types: 'literal', 'identifier', 'operator', 'function', 'open_paren', 'close_paren', 'comma', 'unknown'.
178
+ *
179
+ * @param {string} expression - The mathematical expression string to tokenize.
180
+ * @returns {Array<object>} An array of token objects.
181
+ */
182
+ function tokenizeCore(expression) {
183
+ const tokens = [];
184
+ // Regular expression to capture recognized patterns:
185
+ // 1: Numbers (integer or decimal)
186
+ // 2: Identifiers (variable names, function names, constants like PI) - starting with letter or _, followed by letters, numbers, or _
187
+ // 3: Multi-character comparison operators (>=, <=, ==, !=)
188
+ // 4: Single-character operators, parentheses, comma, or whitespace that might be part of an operator later (like '<' in '<=')
189
+ // 5: Whitespace sequences
190
+ const regex =
191
+ /(\d+(?:\.\d+)?)|([a-zA-Z_][a-zA-Z0-9_]*)|(>=|<=|==|!=)|([\+\-\*\/%^ \(\),<>])|(\s+)/g;
192
+ let match;
193
+ let lastToken = null; // Keep track of the previous token to help identify unary minus
194
+ let expectedIndex = 0; // Track the expected start index of the next token
195
+
196
+ // Iterate through all matches found by the regex in the expression string
197
+ while ((match = regex.exec(expression)) !== null) {
198
+ /* ... */ // Assume original complex logic might be here, focusing on the provided snippet
199
+
200
+ // Check for unrecognized character sequences between valid tokens
201
+ if (match.index !== expectedIndex) {
202
+ const gap = expression.substring(expectedIndex, match.index);
203
+ // Ignore gaps that are only whitespace
204
+ if (gap.trim() !== "") {
205
+ // Issue a warning for unrecognized characters, but attempt to continue tokenizing
206
+ console.warn(
207
+ `Invalid character sequence found near index ${expectedIndex}: '${gap}'`
208
+ );
209
+ // Note: Consider adding an 'error' or 'unknown_sequence' token type if needed for stricter parsing downstream
210
+ }
211
+ }
212
+
213
+ let tokenStr = match[0]; // The matched string
214
+ let token; // The token object to be created
215
+
216
+ // Group 5: Whitespace
217
+ if (match[5]) {
218
+ // Ignore whitespace; simply advance the expected index
219
+ expectedIndex = regex.lastIndex;
220
+ continue; // Move to the next match
221
+ }
222
+
223
+ // Group 1: Literal (Number)
224
+ if (match[1]) {
225
+ token = { type: "literal", value: parseFloat(tokenStr) };
226
+ }
227
+ // Group 2: Identifier (Constant or Function Name)
228
+ else if (match[2]) {
229
+ if (tokenStr in constants) {
230
+ // If it's a known constant, treat it as a literal value
231
+ token = { type: "literal", value: constants[tokenStr] };
232
+ } else if (tokenStr in functions) {
233
+ // If it's a known function name
234
+ token = { type: "function", value: tokenStr };
235
+ } else {
236
+ // If it's not a known constant or function
237
+ console.warn(`Unknown identifier: ${tokenStr}`);
238
+ // Create an 'unknown' token type. This allows the process to continue,
239
+ // but downstream functions (like Shunting-Yard or evaluator) should handle or ignore it.
240
+ token = { type: "unknown", value: tokenStr };
241
+ }
242
+ }
243
+ // Group 3: Comparison Operators (>=, <=, ==, !=)
244
+ else if (match[3]) {
245
+ token = {
246
+ type: "operator",
247
+ value: tokenStr,
248
+ precedence: 1, // Lower precedence than arithmetic operators
249
+ associativity: "left"
250
+ };
251
+ }
252
+ // Group 4: Other Operators/Punctuation (+, -, *, /, %, ^, (, ), ,, <, >)
253
+ else if (match[4]) {
254
+ tokenStr = tokenStr.trim(); // Remove surrounding whitespace if captured by the regex group
255
+ // This check should ideally not be needed if regex correctly excludes pure whitespace via group 5, but acts as a safeguard.
256
+ if (!tokenStr) {
257
+ expectedIndex = regex.lastIndex;
258
+ continue;
259
+ }
260
+
261
+ // Distinguish between unary minus and binary subtraction
262
+ if (tokenStr === "-") {
263
+ const isUnary =
264
+ lastToken === null || // Beginning of expression
265
+ ["operator", "open_paren", "comma"].includes(
266
+ lastToken?.type
267
+ ); // Following an operator, open parenthesis, or comma
268
+
269
+ token = isUnary
270
+ ? {
271
+ // Unary minus
272
+ type: "operator",
273
+ value: "unary-", // Special value to distinguish from binary minus
274
+ precedence: 4, // Higher precedence than multiplication/division
275
+ associativity: "right"
276
+ }
277
+ : {
278
+ // Binary minus (subtraction)
279
+ type: "operator",
280
+ value: "-",
281
+ precedence: 2, // Same precedence as addition
282
+ associativity: "left"
283
+ };
284
+ } else if (tokenStr === "+") {
285
+ // Note: Unary plus is often ignored or handled implicitly, but could be tokenized similarly if needed.
286
+ token = {
287
+ type: "operator",
288
+ value: "+",
289
+ precedence: 2,
290
+ associativity: "left"
291
+ };
292
+ } else if (tokenStr === "*" || tokenStr === "/") {
293
+ token = {
294
+ type: "operator",
295
+ value: tokenStr,
296
+ precedence: 3,
297
+ associativity: "left"
298
+ };
299
+ } else if (tokenStr === "%") {
300
+ // Modulo operator
301
+ token = {
302
+ type: "operator",
303
+ value: tokenStr,
304
+ precedence: 3,
305
+ associativity: "left"
306
+ };
307
+ } else if (tokenStr === "^") {
308
+ // Exponentiation operator
309
+ token = {
310
+ type: "operator",
311
+ value: "^",
312
+ precedence: 5,
313
+ associativity: "right"
314
+ }; // Highest precedence, right-associative
315
+ } else if (tokenStr === ">" || tokenStr === "<") {
316
+ // Simple comparison operators
317
+ token = {
318
+ type: "operator",
319
+ value: tokenStr,
320
+ precedence: 1,
321
+ associativity: "left"
322
+ }; // Same low precedence as other comparisons
323
+ } else if (tokenStr === "(") {
324
+ token = { type: "open_paren", value: "(" };
325
+ } else if (tokenStr === ")") {
326
+ token = { type: "close_paren", value: ")" };
327
+ } else if (tokenStr === ",") {
328
+ // Comma, typically used as function argument separator
329
+ token = { type: "comma", value: "," };
330
+ } else {
331
+ // If the character is captured by group 4 but isn't handled above
332
+ console.warn(
333
+ `Unhandled punctuation/operator token: '${tokenStr}'`
334
+ );
335
+ // Mark as unknown
336
+ token = { type: "unknown", value: tokenStr };
337
+ }
338
+ } else {
339
+ // This block should theoretically not be reached if the regex covers all cases correctly.
340
+ // It acts as a fallback error indicator.
341
+ console.warn(
342
+ `Tokenizer internal regex error: No group matched near '${expression.substring(
343
+ expectedIndex
344
+ )}'`
345
+ );
346
+ // Optionally create an 'error' token or skip
347
+ }
348
+
349
+ // If a valid (or unknown) token was created, add it to the list
350
+ if (token) {
351
+ tokens.push(token);
352
+ lastToken = token; // Update lastToken for the next iteration's unary minus check
353
+ }
354
+
355
+ // Advance the expected starting position for the next token search
356
+ expectedIndex = regex.lastIndex;
357
+ }
358
+
359
+ // After the loop, check if the entire string was consumed by the tokenizer
360
+ if (expectedIndex < expression.length) {
361
+ const trailing = expression.substring(expectedIndex).trim();
362
+ // If there are non-whitespace characters remaining, they were not tokenized
363
+ if (trailing) {
364
+ console.warn(`Invalid trailing characters ignored: '${trailing}'`);
365
+ }
366
+ }
367
+
368
+ return tokens;
91
369
  }
92
370
 
93
- function initEvent(element, input) {
94
- input.addEventListener('input', function () {
95
- setCalcationResult(element);
96
- });
97
-
98
- // if (input.hasAttribute('calculate')) {
99
- // input.addEventListener('changedCalcValue', function(e) {
100
- // setCalcationResult(element);
101
- // });
102
- // }
103
- // setCalcationResult(element);
371
+ /**
372
+ * Converts an infix token stream (from tokenizeCore) to a Reverse Polish Notation (RPN) queue.
373
+ * Implements the Shunting-yard algorithm. Does not handle ternary operators directly.
374
+ * Handles operator precedence and associativity, functions, and parentheses.
375
+ *
376
+ * @param {Array<object>} tokens - The array of token objects from tokenizeCore.
377
+ * @returns {Array<object>} An array of token objects arranged in RPN order.
378
+ */
379
+ function shuntingYardCore(tokens) {
380
+ const outputQueue = []; // Stores the RPN output
381
+ const operatorStack = []; // Temporary stack for operators, functions, and parentheses
382
+
383
+ // Helper function to view the top element of a stack without removing it
384
+ const peek = (stack) => (stack.length > 0 ? stack[stack.length - 1] : null);
385
+
386
+ // Process each token from the input array
387
+ for (const token of tokens) {
388
+ // If the token is invalid or marked as unknown by the tokenizer, skip it.
389
+ if (!token || token.type === "unknown") {
390
+ console.warn(
391
+ `Shunting-Yard skipping unknown token: ${token?.value}`
392
+ );
393
+ continue;
394
+ }
395
+
396
+ /* ... */ // Assume original SY logic structure might be here
397
+
398
+ // Handle token based on its type
399
+ switch (token.type) {
400
+ case "literal":
401
+ // Literals (numbers) are immediately added to the output queue.
402
+ outputQueue.push(token);
403
+ break;
404
+
405
+ case "function":
406
+ // Functions are pushed onto the operator stack.
407
+ operatorStack.push(token);
408
+ break;
409
+
410
+ case "comma":
411
+ // Commas indicate separation of arguments in a function call.
412
+ // Pop operators from the stack to the output until an opening parenthesis is found.
413
+ while (peek(operatorStack)?.type !== "open_paren") {
414
+ const topOp = peek(operatorStack);
415
+ // If the stack becomes empty before finding '(', it implies mismatched parentheses or comma.
416
+ if (topOp === null) {
417
+ console.warn(
418
+ "Mismatched comma or parentheses detected during comma handling."
419
+ );
420
+ // Break to prevent infinite loop in error case. Consider throwing an error for stricter handling.
421
+ break;
422
+ }
423
+ outputQueue.push(operatorStack.pop());
424
+ }
425
+ // The '(' remains on the stack to mark the start of the arguments.
426
+ break;
427
+
428
+ case "operator":
429
+ // Handle operators based on precedence and associativity.
430
+ const currentOp = token;
431
+ let topOp = peek(operatorStack);
432
+ // While there's an operator on the stack with higher or equal precedence (considering associativity)...
433
+ while (
434
+ topOp?.type === "operator" &&
435
+ ((currentOp.associativity === "left" &&
436
+ currentOp.precedence <= topOp.precedence) ||
437
+ (currentOp.associativity === "right" &&
438
+ currentOp.precedence < topOp.precedence))
439
+ ) {
440
+ // Pop the operator from the stack to the output queue.
441
+ outputQueue.push(operatorStack.pop());
442
+ topOp = peek(operatorStack); // Check the new top operator
443
+ }
444
+ // Push the current operator onto the stack.
445
+ operatorStack.push(currentOp);
446
+ break;
447
+
448
+ case "open_paren":
449
+ // Opening parentheses are always pushed onto the operator stack.
450
+ operatorStack.push(token);
451
+ break;
452
+
453
+ case "close_paren":
454
+ // Closing parenthesis: process operators until the matching opening parenthesis.
455
+ let foundOpenParen = false;
456
+ while (peek(operatorStack)?.type !== "open_paren") {
457
+ const opToPop = operatorStack.pop();
458
+ // If the stack runs out before finding '(', parentheses are mismatched.
459
+ if (!opToPop) {
460
+ console.warn(
461
+ "Mismatched parentheses: Closing parenthesis found without matching open parenthesis."
462
+ );
463
+ // Break to prevent potential infinite loop if stack is empty.
464
+ break;
465
+ }
466
+ outputQueue.push(opToPop);
467
+ }
468
+
469
+ // If an opening parenthesis was found, pop it from the stack (it's not added to output).
470
+ if (peek(operatorStack)?.type === "open_paren") {
471
+ operatorStack.pop();
472
+ foundOpenParen = true;
473
+ } // Mismatch case already warned inside the loop
474
+
475
+ // If the token preceding the parenthesis pair was a function name, pop it to the output.
476
+ // This places the function after its arguments in RPN.
477
+ if (peek(operatorStack)?.type === "function") {
478
+ outputQueue.push(operatorStack.pop());
479
+ }
480
+ break;
481
+
482
+ default:
483
+ // Should not happen if tokenizer provides known types, but acts as a safeguard.
484
+ console.warn(
485
+ `Unknown token type encountered in Shunting-Yard: ${token.type}`
486
+ );
487
+ }
488
+ }
489
+
490
+ // After processing all tokens, pop any remaining operators/functions from the stack to the output queue.
491
+ while (peek(operatorStack) !== null) {
492
+ const op = operatorStack.pop();
493
+ // If an opening parenthesis is found here, it means parentheses were mismatched.
494
+ if (op.type === "open_paren") {
495
+ console.warn(
496
+ "Mismatched parentheses: Open parenthesis remaining on stack at the end."
497
+ );
498
+ // Continue processing other operators, but the RPN is likely invalid.
499
+ } else {
500
+ outputQueue.push(op);
501
+ }
502
+ }
503
+
504
+ return outputQueue; // Return the final RPN token queue.
104
505
  }
105
506
 
106
- async function setCalcationResult(element) {
107
- const { object, isRealtime } = getAttributes(element);
108
-
109
- let calString = await getValues(element.getAttribute('calculate'));
110
-
111
- if (calString) {
112
- let result = calculate(calString);
113
-
114
- // TODO: input event below triggers save for all input elements but will not save for regular elements
115
- if (element.setValue) {
116
- element.setValue(result)
117
- if (object && isRealtime && isRealtime !== "false") {
118
- element.save(element);
119
- }
120
- }
121
- else {
122
- // if (element.value){
123
-
124
- // }
125
- // else {
126
- element.innerHTML = result;
127
- // }
128
- }
129
-
130
- let inputEvent = new CustomEvent('input', { bubbles: true });
131
- Object.defineProperty(inputEvent, 'target', { writable: false, value: element });
132
- element.dispatchEvent(inputEvent);
133
-
134
- //. set custom event
135
- // var event = new CustomEvent('changedCalcValue', {
136
- // bubbles: true,
137
- // });
138
- // element.dispatchEvent(event);
139
- }
507
+ /**
508
+ * Evaluates a Reverse Polish Notation (RPN) token queue generated by shuntingYardCore.
509
+ * Performs the actual calculations based on operators and function calls.
510
+ * Handles basic error conditions like stack underflow, division by zero, and unknown tokens.
511
+ * Returns the numerical result or null if evaluation fails.
512
+ *
513
+ * @param {Array<object>} rpnQueue - The array of token objects in RPN order.
514
+ * @returns {number | null} The calculated numerical result, or null if an error occurs.
515
+ */
516
+ function evaluateRPNCore(rpnQueue) {
517
+ if (!rpnQueue || rpnQueue.length === 0) {
518
+ return null;
519
+ }
520
+
521
+ const evaluationStack = []; // Stack used to hold operands during RPN evaluation.
522
+
523
+ for (const token of rpnQueue) {
524
+ if (token.type === "literal") {
525
+ evaluationStack.push(token.value);
526
+ } else if (token.type === "operator") {
527
+ if (token.value === "unary-") {
528
+ if (evaluationStack.length < 1) {
529
+ console.warn(
530
+ `Stack underflow error during unary '-' operation.`
531
+ );
532
+ return null;
533
+ }
534
+ // Pop the operand, negate it, and push the result back.
535
+ evaluationStack.push(-evaluationStack.pop());
536
+ }
537
+ // Handle binary operators
538
+ else {
539
+ // Requires two operands on the stack.
540
+ if (evaluationStack.length < 2) {
541
+ console.warn(
542
+ `Stack underflow error during binary '${token.value}' operation.`
543
+ );
544
+ return null;
545
+ }
546
+ // Pop the top two operands. Note: the second operand (b) is popped first.
547
+ const b = evaluationStack.pop();
548
+ const a = evaluationStack.pop();
549
+ let result;
550
+
551
+ // Perform the operation based on the operator value.
552
+ switch (token.value) {
553
+ case "+":
554
+ result = a + b;
555
+ break;
556
+ case "-":
557
+ result = a - b;
558
+ break;
559
+ case "*":
560
+ result = a * b;
561
+ break;
562
+ case "/":
563
+ // Check for division by zero.
564
+ if (b === 0) {
565
+ console.warn("Division by zero encountered.");
566
+ return null;
567
+ }
568
+ result = a / b;
569
+ break;
570
+ case "%":
571
+ // Check for modulo by zero (JavaScript's % operator returns NaN in this case).
572
+ if (b === 0) {
573
+ console.warn("Modulo by zero encountered.");
574
+ return null; // Return null for consistency with division by zero.
575
+ }
576
+ result = a % b;
577
+ break;
578
+ case "^":
579
+ result = Math.pow(a, b);
580
+ break;
581
+ // Comparison operators return 1 for true, 0 for false, consistent with C-like behavior.
582
+ case ">":
583
+ result = a > b ? 1 : 0;
584
+ break;
585
+ case "<":
586
+ result = a < b ? 1 : 0;
587
+ break;
588
+ case ">=":
589
+ result = a >= b ? 1 : 0;
590
+ break;
591
+ case "<=":
592
+ result = a <= b ? 1 : 0;
593
+ break;
594
+ case "==":
595
+ result = a === b ? 1 : 0;
596
+ break; // Use strict equality
597
+ case "!=":
598
+ result = a !== b ? 1 : 0;
599
+ break; // Use strict inequality
600
+ default:
601
+ console.warn(
602
+ `Unknown operator encountered during evaluation: ${token.value}`
603
+ );
604
+ return null;
605
+ }
606
+ evaluationStack.push(result);
607
+ }
608
+ }
609
+ // If the token is a function call...
610
+ else if (token.type === "function") {
611
+ // Look up the function implementation (assuming 'functions' is a globally accessible object/map).
612
+ const func = functions[token.value];
613
+ if (!func) {
614
+ // If the function name is not found in the available functions.
615
+ console.warn(
616
+ `Unknown function encountered during evaluation: ${token.value}`
617
+ );
618
+ return null;
619
+ }
620
+
621
+ // Determine the expected number of arguments (arity) for the function.
622
+ // Note: Relying solely on func.length can be unreliable for functions with default parameters or rest parameters.
623
+ // This example uses a mix of func.length and hardcoded arity for common Math functions.
624
+ // A more robust implementation might store arity explicitly alongside the function definition.
625
+ let arity = func.length; // Default assumption based on function definition
626
+ // Explicitly define arity for functions where .length might be ambiguous or for built-ins.
627
+ // (Example adjustments - tailor these to the actual functions defined)
628
+ if (["max", "min", "pow"].includes(token.value)) arity = 2;
629
+ if (
630
+ [
631
+ "sqrt",
632
+ "abs",
633
+ "ceil",
634
+ "floor",
635
+ "round",
636
+ "log",
637
+ "sin",
638
+ "cos",
639
+ "tan"
640
+ ].includes(token.value)
641
+ )
642
+ arity = 1;
643
+ // Add more overrides as needed for your specific function set.
644
+
645
+ // Check if there are enough operands on the stack for the function's arity.
646
+ if (evaluationStack.length < arity) {
647
+ console.warn(
648
+ `Stack underflow for function '${token.value}'. Need ${arity} args, found ${evaluationStack.length}.`
649
+ );
650
+ return null;
651
+ }
652
+
653
+ // Pop the required number of arguments from the stack.
654
+ const args = [];
655
+ for (let i = 0; i < arity; i++) {
656
+ args.push(evaluationStack.pop());
657
+ }
658
+
659
+ try {
660
+ // Call the function with the arguments. Since they were popped in reverse,
661
+ const functionResult = func(...args.reverse());
662
+ evaluationStack.push(functionResult);
663
+ } catch (funcError) {
664
+ // Catch errors that might occur during the function's execution (e.g., Math.log(-1) -> NaN, invalid inputs).
665
+ console.warn(
666
+ `Error executing function '${token.value}': ${funcError.message}`
667
+ );
668
+ return null;
669
+ }
670
+ } else {
671
+ // If a token type other than literal, operator, or function appears in the RPN queue.
672
+ // This might indicate an error in the RPN generation (Shunting-Yard).
673
+ console.warn(
674
+ `Unknown RPN token type encountered: ${token?.type} (Value: ${token?.value})`
675
+ );
676
+ return null;
677
+ }
678
+ }
679
+
680
+ // After processing all tokens, the evaluation stack should contain exactly one value: the final result.
681
+ if (evaluationStack.length !== 1) {
682
+ // If the stack size is not 1, it usually indicates an invalid expression or a bug in the RPN conversion/evaluation.
683
+ console.warn(
684
+ `Evaluation finished with invalid stack size: ${
685
+ evaluationStack.length
686
+ }. Contents: ${JSON.stringify(evaluationStack)}`
687
+ );
688
+ return null;
689
+ }
690
+
691
+ const finalResult = evaluationStack[0];
692
+
693
+ // Validate the final result to ensure it's a usable number.
694
+ // Allow 0 and 1 specifically, as they are valid results from boolean comparisons.
695
+ if (finalResult === 0 || finalResult === 1) {
696
+ return finalResult;
697
+ }
698
+ // Check if the result is a finite number (not NaN, Infinity, or -Infinity).
699
+ if (typeof finalResult !== "number" || !Number.isFinite(finalResult)) {
700
+ console.warn(
701
+ `Final evaluation result is not a valid finite number: ${finalResult}`
702
+ );
703
+ return null;
704
+ }
705
+
706
+ return finalResult;
707
+ }
140
708
 
709
+ /**
710
+ * Parses a string expression to find the components of a *top-level* ternary expression.
711
+ * Looks for the first '?' and its corresponding ':' at the same parenthesis nesting level.
712
+ * Returns an object with { condition, trueExpr, falseExpr } if found, otherwise null.
713
+ * Respects parentheses to avoid splitting nested ternaries incorrectly.
714
+ *
715
+ * @param {string} expression - The expression string to parse.
716
+ * @returns {{condition: string, trueExpr: string, falseExpr: string} | null} Object with parts or null.
717
+ */
718
+ function parseTernary(expression) {
719
+ let parenLevel = 0; // Tracks nesting level of parentheses
720
+ let qIndex = -1; // Index of the top-level '?'
721
+
722
+ // First pass: Find the first '?' at parenthesis level 0.
723
+ for (let i = 0; i < expression.length; i++) {
724
+ const char = expression[i];
725
+ if (char === "(") {
726
+ parenLevel++;
727
+ } else if (char === ")") {
728
+ parenLevel--;
729
+ } else if (char === "?" && parenLevel === 0) {
730
+ // Found the '?' at the top level
731
+ qIndex = i;
732
+ break; // Stop searching once the first top-level '?' is found
733
+ }
734
+
735
+ // Error check: If parenLevel goes below 0, parentheses are mismatched.
736
+ if (parenLevel < 0) {
737
+ console.warn(
738
+ `Mismatched parentheses detected (too many ')') in ternary structure near index ${i}.`
739
+ );
740
+ return null; // Indicate parsing failure due to invalid structure
741
+ }
742
+ }
743
+
744
+ // If no top-level '?' was found, it's not a simple ternary structure at this level.
745
+ if (qIndex === -1) {
746
+ return null;
747
+ }
748
+
749
+ // Second pass: Find the corresponding ':' at level 0, starting *after* the '?'.
750
+ parenLevel = 0; // Reset parenthesis level counter for the colon search
751
+ let cIndex = -1; // Index of the top-level ':'
752
+ for (let i = qIndex + 1; i < expression.length; i++) {
753
+ const char = expression[i];
754
+ if (char === "(") {
755
+ parenLevel++;
756
+ } else if (char === ")") {
757
+ parenLevel--;
758
+ } else if (char === ":" && parenLevel === 0) {
759
+ // Found the matching ':' at the top level
760
+ cIndex = i;
761
+ break; // Stop searching
762
+ }
763
+ // Error check during colon search
764
+ if (parenLevel < 0) {
765
+ console.warn(
766
+ `Mismatched parentheses detected (too many ')') after '?' in ternary structure near index ${i}.`
767
+ );
768
+ return null; // Indicate parsing failure
769
+ }
770
+ }
771
+
772
+ // If no matching top-level ':' was found after the '?', the structure is invalid.
773
+ if (cIndex === -1) {
774
+ console.warn(
775
+ `Invalid ternary structure: No matching top-level ':' found for '?' at index ${qIndex}.`
776
+ );
777
+ return null;
778
+ }
779
+
780
+ // Extract the three parts of the ternary expression.
781
+ const condition = expression.substring(0, qIndex).trim();
782
+ const trueExpr = expression.substring(qIndex + 1, cIndex).trim();
783
+ const falseExpr = expression.substring(cIndex + 1).trim();
784
+
785
+ // Validate that none of the parts are empty after trimming.
786
+ if (!condition || !trueExpr || !falseExpr) {
787
+ console.warn(
788
+ `Invalid ternary structure: empty part detected in "${expression}". Condition: "${condition}", True: "${trueExpr}", False: "${falseExpr}".`
789
+ );
790
+ return null;
791
+ }
792
+
793
+ return { condition, trueExpr, falseExpr };
141
794
  }
142
795
 
143
- function calculate(string) {
144
- if (/^[0-9+\-*/()%||?\s:=.]*$/.test(string))
145
- return eval(string);
796
+ /**
797
+ * Main entry point for evaluating a mathematical expression string.
798
+ * Handles nested ternary operators (`? :`) recursively with short-circuiting.
799
+ * For non-ternary expressions or sub-expressions, it uses the core engine:
800
+ * Tokenizer -> Shunting-Yard -> RPN Evaluator.
801
+ * Provides graceful handling of common errors, returning null on failure.
802
+ *
803
+ * @param {string | any} expression - The expression string to evaluate. Non-string inputs are converted.
804
+ * @returns {number | null} The final calculated result, or null if evaluation fails or the expression is invalid.
805
+ */
806
+ function calculate(expression) {
807
+ // Store the original input, converting to string if necessary, for logging context.
808
+ const originalExpr =
809
+ typeof expression === "string" ? expression : String(expression);
810
+
811
+ try {
812
+ // Ensure we are working with a trimmed string.
813
+ let currentExpr =
814
+ typeof expression === "string" ? expression.trim() : "";
815
+
816
+ // Handle empty or whitespace-only expressions immediately.
817
+ if (!currentExpr) {
818
+ // Warning is optional here, depends if empty input is expected or an error.
819
+ // console.warn("Expression is empty or evaluates to empty string.");
820
+ return null; // Return null for empty expression.
821
+ }
822
+
823
+ /* --- Optional Step: Remove Fully Wrapping Parentheses ---
824
+ * This simplifies parsing by removing redundant outer parentheses, e.g., "((1 + 2))" becomes "1 + 2".
825
+ * It iteratively unwraps as long as the outermost characters are '(' and ')'
826
+ * and they correctly balance across the entire contained expression.
827
+ */
828
+ let unwrapped = false; // Flag to track if any unwrapping occurred (mainly for debugging)
829
+ while (currentExpr.startsWith("(") && currentExpr.endsWith(")")) {
830
+ let balance = 0;
831
+ let canUnwrap = true; // Assume it can be unwrapped unless proven otherwise
832
+
833
+ // Handle edge case like "()" which cannot be unwrapped to an empty string meaningfully here.
834
+ if (currentExpr.length <= 2) {
835
+ canUnwrap = false;
836
+ break;
837
+ }
838
+
839
+ // Check if the parentheses truly wrap the *entire* internal expression.
840
+ for (let i = 0; i < currentExpr.length; i++) {
841
+ if (currentExpr[i] === "(") balance++;
842
+ else if (currentExpr[i] === ")") balance--;
843
+
844
+ // If balance returns to 0 *before* the very last character,
845
+ // it means the parentheses don't wrap the whole thing, e.g., "(1) + (2)".
846
+ if (balance === 0 && i < currentExpr.length - 1) {
847
+ canUnwrap = false;
848
+ break;
849
+ }
850
+ // If balance goes negative at any point, parentheses are mismatched.
851
+ if (balance < 0) {
852
+ canUnwrap = false; // Should ideally be caught later, but good safeguard.
853
+ break;
854
+ }
855
+ }
856
+
857
+ // The final balance must also be 0 for the wrapping to be valid.
858
+ if (balance !== 0) {
859
+ canUnwrap = false;
860
+ }
861
+
862
+ // If the checks pass, perform the unwrap.
863
+ if (canUnwrap) {
864
+ currentExpr = currentExpr
865
+ .substring(1, currentExpr.length - 1)
866
+ .trim();
867
+ unwrapped = true;
868
+ } else {
869
+ // If cannot unwrap this layer, stop the unwrapping process.
870
+ break;
871
+ }
872
+ }
873
+ /* --- End Parenthesis Unwrapping --- */
874
+
875
+ // 1. Attempt to parse the current (potentially unwrapped) expression as a top-level ternary.
876
+ const ternaryParts = parseTernary(currentExpr);
877
+
878
+ // 2. If it successfully parsed as a ternary structure...
879
+ if (ternaryParts) {
880
+ // 2a. Recursively evaluate the condition part first.
881
+ const condResult = calculate(ternaryParts.condition);
882
+
883
+ // Handle cases where the condition itself fails to evaluate.
884
+ if (condResult === null) {
885
+ // Log a warning indicating the condition evaluation failed.
886
+ console.warn(
887
+ `Failed to evaluate ternary condition: "${ternaryParts.condition}" in context: "${originalExpr}". Defaulting to false branch.`
888
+ );
889
+ // Proceed as if the condition is false for robustness, evaluating the 'falseExpr'.
890
+ // Alternatively, could return null here to propagate the failure.
891
+ return calculate(ternaryParts.falseExpr);
892
+ }
893
+
894
+ // 2b. Short-circuiting: Evaluate *only* the required branch based on the condition result.
895
+ // The core evaluator returns 1 for true comparisons, 0 for false.
896
+ // Any non-zero number is treated as "truthy" here.
897
+ if (condResult) {
898
+ // Checks for truthiness (non-zero result)
899
+ return calculate(ternaryParts.trueExpr); // Evaluate the true branch recursively.
900
+ } else {
901
+ return calculate(ternaryParts.falseExpr); // Evaluate the false branch recursively.
902
+ }
903
+ }
904
+ // 3. If it's not a top-level ternary (or parseTernary returned null due to errors)...
905
+ else {
906
+ // Evaluate the expression using the standard core math engine pipeline.
907
+ const tokens = tokenizeCore(currentExpr);
908
+ const rpnQueue = shuntingYardCore(tokens);
909
+ // evaluateRPNCore handles internal errors (like division by zero, unknown tokens) and returns null on failure.
910
+ const result = evaluateRPNCore(rpnQueue);
911
+ return result; // Return the result (which could be a number or null).
912
+ }
913
+ } catch (error) {
914
+ // Catch any unexpected runtime errors during the calculation process.
915
+ const contextExpr =
916
+ originalExpr.length > 50
917
+ ? originalExpr.substring(0, 47) + "..." // Truncate long expressions for logging
918
+ : originalExpr;
919
+ console.warn(
920
+ `Unexpected calculation error: ${error.message} (Expression context: "${contextExpr}")`,
921
+ error
922
+ );
923
+ return null;
924
+ }
146
925
  }
147
926
 
148
927
  observer.init({
149
- name: 'CoCreateCalculateChangeValue',
150
- observe: ['attributes'],
151
- attributeName: ['calculate'],
152
- callback(mutation) {
153
- setCalcationResult(mutation.target);
154
- }
928
+ name: "CoCreateCalculateChangeValue",
929
+ types: ["attributes"],
930
+ attributeFilter: ["calculate"],
931
+ callback(mutation) {
932
+ setCalcationValue(mutation.target);
933
+ }
155
934
  });
156
935
 
157
936
  observer.init({
158
- name: 'CoCreateCalculateInit',
159
- observe: ['addedNodes'],
160
- selector: '[calculate]',
161
- callback(mutation) {
162
- initElement(mutation.target);
163
- }
937
+ name: "CoCreateCalculateInit",
938
+ types: ["addedNodes"],
939
+ selector: "[calculate]",
940
+ callback(mutation) {
941
+ initElement(mutation.target);
942
+ }
164
943
  });
165
944
 
166
945
  init();