@teachinglab/omd 0.6.1 → 0.6.2

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.
Files changed (198) hide show
  1. package/README.md +257 -251
  2. package/README.old.md +137 -137
  3. package/canvas/core/canvasConfig.js +202 -202
  4. package/canvas/drawing/segment.js +167 -167
  5. package/canvas/drawing/stroke.js +385 -385
  6. package/canvas/events/eventManager.js +444 -444
  7. package/canvas/events/pointerEventHandler.js +262 -262
  8. package/canvas/index.js +48 -48
  9. package/canvas/tools/PointerTool.js +71 -71
  10. package/canvas/tools/tool.js +222 -222
  11. package/canvas/utils/boundingBox.js +377 -377
  12. package/canvas/utils/mathUtils.js +258 -258
  13. package/docs/api/configuration-options.md +198 -198
  14. package/docs/api/eventManager.md +82 -82
  15. package/docs/api/focusFrameManager.md +144 -144
  16. package/docs/api/index.md +105 -105
  17. package/docs/api/main.md +62 -62
  18. package/docs/api/omdBinaryExpressionNode.md +86 -86
  19. package/docs/api/omdCanvas.md +83 -83
  20. package/docs/api/omdConfigManager.md +112 -112
  21. package/docs/api/omdConstantNode.md +52 -52
  22. package/docs/api/omdDisplay.md +87 -87
  23. package/docs/api/omdEquationNode.md +174 -174
  24. package/docs/api/omdEquationSequenceNode.md +258 -258
  25. package/docs/api/omdEquationStack.md +192 -192
  26. package/docs/api/omdFunctionNode.md +82 -82
  27. package/docs/api/omdGroupNode.md +78 -78
  28. package/docs/api/omdHelpers.md +87 -87
  29. package/docs/api/omdLeafNode.md +85 -85
  30. package/docs/api/omdNode.md +201 -201
  31. package/docs/api/omdOperationDisplayNode.md +117 -117
  32. package/docs/api/omdOperatorNode.md +91 -91
  33. package/docs/api/omdParenthesisNode.md +133 -133
  34. package/docs/api/omdPopup.md +191 -191
  35. package/docs/api/omdPowerNode.md +131 -131
  36. package/docs/api/omdRationalNode.md +144 -144
  37. package/docs/api/omdSequenceNode.md +128 -128
  38. package/docs/api/omdSimplification.md +78 -78
  39. package/docs/api/omdSqrtNode.md +144 -144
  40. package/docs/api/omdStepVisualizer.md +146 -146
  41. package/docs/api/omdStepVisualizerHighlighting.md +65 -65
  42. package/docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  43. package/docs/api/omdStepVisualizerLayout.md +70 -70
  44. package/docs/api/omdStepVisualizerNodeUtils.md +140 -140
  45. package/docs/api/omdStepVisualizerTextBoxes.md +76 -76
  46. package/docs/api/omdToolbar.md +130 -130
  47. package/docs/api/omdTranscriptionService.md +95 -95
  48. package/docs/api/omdTreeDiff.md +169 -169
  49. package/docs/api/omdUnaryExpressionNode.md +137 -137
  50. package/docs/api/omdUtilities.md +82 -82
  51. package/docs/api/omdVariableNode.md +123 -123
  52. package/docs/api/selectTool.md +74 -74
  53. package/docs/api/simplificationEngine.md +97 -97
  54. package/docs/api/simplificationRules.md +76 -76
  55. package/docs/api/simplificationUtils.md +64 -64
  56. package/docs/api/transcribe.md +43 -43
  57. package/docs/api-reference.md +85 -85
  58. package/docs/index.html +453 -453
  59. package/docs/index.md +38 -38
  60. package/docs/omd-objects.md +258 -258
  61. package/index.js +79 -79
  62. package/jsvg/index.js +3 -0
  63. package/jsvg/jsvg.js +898 -898
  64. package/jsvg/jsvgComponents.js +357 -358
  65. package/npm-docs/DOCUMENTATION_SUMMARY.md +220 -220
  66. package/npm-docs/README.md +251 -251
  67. package/npm-docs/api/api-reference.md +85 -85
  68. package/npm-docs/api/configuration-options.md +198 -198
  69. package/npm-docs/api/eventManager.md +82 -82
  70. package/npm-docs/api/expression-nodes.md +561 -561
  71. package/npm-docs/api/focusFrameManager.md +144 -144
  72. package/npm-docs/api/index.md +105 -105
  73. package/npm-docs/api/main.md +62 -62
  74. package/npm-docs/api/omdBinaryExpressionNode.md +86 -86
  75. package/npm-docs/api/omdCanvas.md +83 -83
  76. package/npm-docs/api/omdConfigManager.md +112 -112
  77. package/npm-docs/api/omdConstantNode.md +52 -52
  78. package/npm-docs/api/omdDisplay.md +87 -87
  79. package/npm-docs/api/omdEquationNode.md +174 -174
  80. package/npm-docs/api/omdEquationSequenceNode.md +258 -258
  81. package/npm-docs/api/omdEquationStack.md +192 -192
  82. package/npm-docs/api/omdFunctionNode.md +82 -82
  83. package/npm-docs/api/omdGroupNode.md +78 -78
  84. package/npm-docs/api/omdHelpers.md +87 -87
  85. package/npm-docs/api/omdLeafNode.md +85 -85
  86. package/npm-docs/api/omdNode.md +201 -201
  87. package/npm-docs/api/omdOperationDisplayNode.md +117 -117
  88. package/npm-docs/api/omdOperatorNode.md +91 -91
  89. package/npm-docs/api/omdParenthesisNode.md +133 -133
  90. package/npm-docs/api/omdPopup.md +191 -191
  91. package/npm-docs/api/omdPowerNode.md +131 -131
  92. package/npm-docs/api/omdRationalNode.md +144 -144
  93. package/npm-docs/api/omdSequenceNode.md +128 -128
  94. package/npm-docs/api/omdSimplification.md +78 -78
  95. package/npm-docs/api/omdSqrtNode.md +144 -144
  96. package/npm-docs/api/omdStepVisualizer.md +146 -146
  97. package/npm-docs/api/omdStepVisualizerHighlighting.md +65 -65
  98. package/npm-docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  99. package/npm-docs/api/omdStepVisualizerLayout.md +70 -70
  100. package/npm-docs/api/omdStepVisualizerNodeUtils.md +140 -140
  101. package/npm-docs/api/omdStepVisualizerTextBoxes.md +76 -76
  102. package/npm-docs/api/omdToolbar.md +130 -130
  103. package/npm-docs/api/omdTranscriptionService.md +95 -95
  104. package/npm-docs/api/omdTreeDiff.md +169 -169
  105. package/npm-docs/api/omdUnaryExpressionNode.md +137 -137
  106. package/npm-docs/api/omdUtilities.md +82 -82
  107. package/npm-docs/api/omdVariableNode.md +123 -123
  108. package/npm-docs/api/selectTool.md +74 -74
  109. package/npm-docs/api/simplificationEngine.md +97 -97
  110. package/npm-docs/api/simplificationRules.md +76 -76
  111. package/npm-docs/api/simplificationUtils.md +64 -64
  112. package/npm-docs/api/transcribe.md +43 -43
  113. package/npm-docs/guides/equations.md +854 -854
  114. package/npm-docs/guides/factory-functions.md +354 -354
  115. package/npm-docs/guides/getting-started.md +318 -318
  116. package/npm-docs/guides/quick-examples.md +525 -525
  117. package/npm-docs/guides/visualizations.md +682 -682
  118. package/npm-docs/index.html +12 -0
  119. package/npm-docs/json-schemas.md +826 -826
  120. package/omd/config/omdConfigManager.js +279 -267
  121. package/omd/core/index.js +158 -158
  122. package/omd/core/omdEquationStack.js +546 -546
  123. package/omd/core/omdUtilities.js +113 -113
  124. package/omd/display/omdDisplay.js +969 -962
  125. package/omd/display/omdToolbar.js +501 -501
  126. package/omd/nodes/omdBinaryExpressionNode.js +459 -459
  127. package/omd/nodes/omdConstantNode.js +141 -141
  128. package/omd/nodes/omdEquationNode.js +1327 -1327
  129. package/omd/nodes/omdFunctionNode.js +351 -351
  130. package/omd/nodes/omdGroupNode.js +67 -67
  131. package/omd/nodes/omdLeafNode.js +76 -76
  132. package/omd/nodes/omdNode.js +556 -556
  133. package/omd/nodes/omdOperationDisplayNode.js +321 -321
  134. package/omd/nodes/omdOperatorNode.js +108 -108
  135. package/omd/nodes/omdParenthesisNode.js +292 -292
  136. package/omd/nodes/omdPowerNode.js +235 -235
  137. package/omd/nodes/omdRationalNode.js +295 -295
  138. package/omd/nodes/omdSqrtNode.js +307 -307
  139. package/omd/nodes/omdUnaryExpressionNode.js +227 -227
  140. package/omd/nodes/omdVariableNode.js +122 -122
  141. package/omd/simplification/omdSimplification.js +140 -140
  142. package/omd/simplification/omdSimplificationEngine.js +887 -887
  143. package/omd/simplification/package.json +5 -5
  144. package/omd/simplification/rules/binaryRules.js +1037 -1037
  145. package/omd/simplification/rules/functionRules.js +111 -111
  146. package/omd/simplification/rules/index.js +48 -48
  147. package/omd/simplification/rules/parenthesisRules.js +19 -19
  148. package/omd/simplification/rules/powerRules.js +143 -143
  149. package/omd/simplification/rules/rationalRules.js +725 -725
  150. package/omd/simplification/rules/sqrtRules.js +48 -48
  151. package/omd/simplification/rules/unaryRules.js +37 -37
  152. package/omd/simplification/simplificationRules.js +31 -31
  153. package/omd/simplification/simplificationUtils.js +1055 -1055
  154. package/omd/step-visualizer/omdStepVisualizer.js +947 -947
  155. package/omd/step-visualizer/omdStepVisualizerHighlighting.js +246 -246
  156. package/omd/step-visualizer/omdStepVisualizerLayout.js +892 -892
  157. package/omd/step-visualizer/omdStepVisualizerTextBoxes.js +200 -200
  158. package/omd/utils/aiNextEquationStep.js +106 -106
  159. package/omd/utils/omdNodeOverlay.js +638 -638
  160. package/omd/utils/omdPopup.js +1203 -1203
  161. package/omd/utils/omdStepVisualizerInteractiveSteps.js +684 -684
  162. package/omd/utils/omdStepVisualizerNodeUtils.js +267 -267
  163. package/omd/utils/omdTranscriptionService.js +123 -123
  164. package/omd/utils/omdTreeDiff.js +733 -733
  165. package/package.json +59 -57
  166. package/readme.html +184 -120
  167. package/src/index.js +74 -74
  168. package/src/json-schemas.md +576 -576
  169. package/src/omd-json-samples.js +147 -147
  170. package/src/omdApp.js +391 -391
  171. package/src/omdAppCanvas.js +335 -335
  172. package/src/omdBalanceHanger.js +199 -199
  173. package/src/omdColor.js +13 -13
  174. package/src/omdCoordinatePlane.js +541 -541
  175. package/src/omdExpression.js +115 -115
  176. package/src/omdFactory.js +150 -150
  177. package/src/omdFunction.js +114 -114
  178. package/src/omdMetaExpression.js +290 -290
  179. package/src/omdNaturalExpression.js +563 -563
  180. package/src/omdNode.js +383 -383
  181. package/src/omdNumber.js +52 -52
  182. package/src/omdNumberLine.js +114 -112
  183. package/src/omdNumberTile.js +118 -118
  184. package/src/omdOperator.js +72 -72
  185. package/src/omdPowerExpression.js +91 -91
  186. package/src/omdProblem.js +259 -259
  187. package/src/omdRatioChart.js +251 -251
  188. package/src/omdRationalExpression.js +114 -114
  189. package/src/omdSampleData.js +215 -215
  190. package/src/omdShapes.js +512 -512
  191. package/src/omdSpinner.js +151 -151
  192. package/src/omdString.js +49 -49
  193. package/src/omdTable.js +498 -498
  194. package/src/omdTapeDiagram.js +244 -244
  195. package/src/omdTerm.js +91 -91
  196. package/src/omdTileEquation.js +349 -349
  197. package/src/omdUtils.js +84 -84
  198. package/src/omdVariable.js +51 -51
@@ -1,502 +1,502 @@
1
- import { omdEquationSequenceNode } from '../nodes/omdEquationSequenceNode.js';
2
- import { omdEquationNode } from '../nodes/omdEquationNode.js';
3
- import { omdColor } from '../../src/omdColor.js';
4
- import { jsvgGroup, jsvgRect, jsvgLayoutGroup, jsvgButton } from '@teachinglab/jsvg';
5
-
6
- /**
7
- * A toolbar component for applying mathematical operations to an omdEquationSequenceNode.
8
- */
9
- export class omdToolbar {
10
- /**
11
- * Creates an instance of the omdToolbar.
12
- * @param {jsvgGroup} parentContainer - The parent SVG group to render the toolbar into.
13
- * @param {omdEquationSequenceNode} sequence - The sequence node to apply operations to.
14
- * @param {object} [options={}] - Configuration options for the toolbar.
15
- */
16
- constructor(parentContainer, sequence, options = {}) {
17
- this.parentContainer = parentContainer;
18
- this.sequence = sequence;
19
-
20
- this.config = {
21
- height: 60, padding: 6, spacing: 8, borderRadius: 30,
22
- fontFamily: "'Albert Sans', sans-serif", fontWeight: '500',
23
- colors: { background: omdColor.mediumGray, button: 'white', popup: omdColor.lightGray, undo: '#87D143' },
24
- buttonSize: 48, checkMarkSize: 24, mainFontSize: 32,
25
- inputFontSize: 28, menuFontSize: 24, inputWidth: 120,
26
- popupDirection: 'below',
27
- showUndoButton: false,
28
- undoIconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzciIGhlaWdodD0iNDQiIHZpZXdCb3g9IjAgMCAzNyA0NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik0xOC4zNjc2IDQzLjgzMDFDMTMuNTkxMyA0My44MjM3IDkuMDEyNTYgNDEuOTIzNCA1LjYzNTI1IDM4LjU0NTlDMi4yNTc5MiAzNS4xNjg1IDAuMzU3NjYgMzAuNTg5OSAwLjM1MTA3NCAyNS44MTM2QzAuMzUxMDc0IDI1LjMxOTQgMC41NDc0NDEgMjQuODQ1MiAwLjg5Njk2MSAyNC40OTU4QzEuMjQ2NDggMjQuMTQ2MiAxLjcyMDU1IDIzLjk0OTggMi4yMTQ4NSAyMy45NDk4QzIuNzA5MTUgMjMuOTQ5OCAzLjE4MzIyIDI0LjE0NjIgMy41MzI3NCAyNC40OTU4QzMuODgyMjYgMjQuODQ1MiA0LjA3ODYzIDI1LjMxOTQgNC4wNzg2MyAyNS44MTM2QzQuMDc4NjMgMjguNjM5NiA0LjkxNjY2IDMxLjQwMjIgNi40ODY3NSAzMy43NTIxQzguMDU2ODUgMzYuMTAxOSAxMC4yODg1IDM3LjkzMzQgMTIuODk5NCAzOS4wMTQ5QzE1LjUxMDMgNDAuMDk2NCAxOC4zODM1IDQwLjM3OTQgMjEuMTU1MyAzOS44MjhDMjMuOTI3MSAzOS4yNzY4IDI2LjQ3MyAzNy45MTU3IDI4LjQ3MTUgMzUuOTE3NUMzMC40Njk3IDMzLjkxOTEgMzEuODMwOCAzMS4zNzMxIDMyLjM4MTkgMjguNjAxM0MzMi45MzM0IDI1LjgyOTUgMzIuNjUwMyAyMi45NTYzIDMxLjU2ODggMjAuMzQ1NkMzMC40ODczIDE3LjczNDUgMjguNjU1OSAxNS41MDI5IDI2LjMwNiAxMy45MzI4QzIzLjk1NjIgMTIuMzYyNyAyMS4xOTM2IDExLjUyNDcgMTguMzY3NiAxMS41MjQ3SDEyLjE1NUMxMS42NjA3IDExLjUyNDcgMTEuMTg2NiAxMS4zMjgzIDEwLjgzNzEgMTAuOTc4OEMxMC40ODc2IDEwLjYyOTMgMTAuMjkxMiAxMC4xNTUyIDEwLjI5MTIgOS42NjA5QzEwLjI5MTIgOS4xNjY2IDEwLjQ4NzYgOC42OTI1MyAxMC44MzcxIDguMzQzMDFDMTEuMTg2NiA3Ljk5MzQ5IDExLjY2MDcgNy43OTcxMiAxMi4xNTUgNy43OTcxMkgxOC4zNjc2QzIzLjE0NTggNy43OTcxMiAyNy43Mjg1IDkuNjk1MjkgMzEuMTA3MSAxMy4wNzRDMzQuNDg2IDE2LjQ1MjggMzYuMzg0MSAyMS4wMzU0IDM2LjM4NDEgMjUuODEzNkMzNi4zODQxIDMwLjU5MTkgMzQuNDg2IDM1LjE3NDUgMzEuMTA3MSAzOC41NTMyQzI3LjcyODUgNDEuOTMyMSAyMy4xNDU4IDQzLjgzMDEgMTguMzY3NiA0My44MzAxWiIgZmlsbD0id2hpdGUiLz4NCjxwYXRoIGQ9Ik0xOC4zNjc1IDE4Ljk3OThDMTguMTIyNyAxOC45ODEgMTcuODc5OSAxOC45MzMzIDE3LjY1MzggMTguODM5NEMxNy40Mjc3IDE4Ljc0NTQgMTcuMjIyNyAxOC42MDczIDE3LjA1MDQgMTguNDMzMUw5LjU5NTM2IDEwLjk3OEM5LjI0NjM0IDEwLjYyODYgOS4wNTAyOSAxMC4xNTQ5IDkuMDUwMjkgOS42NjA5NkM5LjA1MDI5IDkuMTY3MDYgOS4yNDYzNCA4LjY5MzM2IDkuNTk1MzYgOC4zNDM4OUwxNy4wNTA0IDAuODg4Nzg5QzE3LjIyMTIgMC43MDU2NjcgMTcuNDI2OSAwLjU1ODgwMSAxNy42NTU1IDAuNDU2OTM5QzE3Ljg4NDIgMC4zNTUwNzggMTguMTMwOSAwLjMwMDMwOCAxOC4zODEyIDAuMjk1ODg0QzE4LjYzMTQgMC4yOTE0NjEgMTguODc5OSAwLjMzNzUwOCAxOS4xMTIgMC40MzEyNDRDMTkuMzQ0MSAwLjUyNDk3OSAxOS41NTQ5IDAuNjY0NDg5IDE5LjczMTggMC44NDE0NzNDMTkuOTA5IDEuMDE4NDYgMjAuMDQ4NCAxLjIyOTI5IDIwLjE0MjEgMS40NjEzNEMyMC4yMzYgMS42OTM0MiAyMC4yODIgMS45NDIgMjAuMjc3NSAyLjE5MjI0QzIwLjI3MyAyLjQ0MjUxIDIwLjIxODQgMi42ODkzIDIwLjExNjUgMi45MTc5MkMyMC4wMTQ2IDMuMTQ2NTQgMTkuODY3NyAzLjM1MjMgMTkuNjg0NiAzLjUyMjkzTDEzLjU0NjUgOS42NjA5NkwxOS42ODQ2IDE1Ljc5OUMyMC4wMzM3IDE2LjE0ODUgMjAuMjI5OCAxNi42MjIyIDIwLjIyOTggMTcuMTE2QzIwLjIyOTggMTcuNjEgMjAuMDMzNyAxOC4wODM3IDE5LjY4NDYgMTguNDMzMUMxOS41MTI2IDE4LjYwNzMgMTkuMzA3MyAxOC43NDU0IDE5LjA4MTIgMTguODM5NEMxOC44NTUxIDE4LjkzMzMgMTguNjEyNSAxOC45ODEgMTguMzY3NSAxOC45Nzk4WiIgZmlsbD0id2hpdGUiLz4NCjwvc3ZnPg0K',
29
- onUndo: null,
30
- ...options
31
- };
32
-
33
- // Support structured styles from equation stack toolbar options
34
- if (options.styles && typeof options.styles === 'object') {
35
- const s = options.styles;
36
- if (s.backgroundColor) this.config.colors.background = s.backgroundColor;
37
- if (s.buttonColor) this.config.colors.button = s.buttonColor;
38
- if (s.popupBackgroundColor) this.config.colors.popup = s.popupBackgroundColor;
39
- if (typeof s.borderRadius === 'number') this.config.borderRadius = s.borderRadius;
40
- if (typeof s.buttonSize === 'number') this.config.buttonSize = s.buttonSize;
41
- if (typeof s.mainFontSize === 'number') this.config.mainFontSize = s.mainFontSize;
42
- if (typeof s.inputFontSize === 'number') this.config.inputFontSize = s.inputFontSize;
43
- if (typeof s.menuFontSize === 'number') this.config.menuFontSize = s.menuFontSize;
44
- if (typeof s.inputWidth === 'number') this.config.inputWidth = s.inputWidth;
45
- if (typeof s.padding === 'number') this.config.padding = s.padding;
46
- if (typeof s.spacing === 'number') this.config.spacing = s.spacing;
47
- }
48
-
49
- // Simple aliases remain supported
50
- if (options.backgroundColor) this.config.colors.background = options.backgroundColor;
51
- if (options.popupBackgroundColor) this.config.colors.popup = options.popupBackgroundColor;
52
-
53
- // If no explicit popup color was provided, default it to the toolbar background color
54
- const popupProvided = !!(options.popupBackgroundColor || (options.styles && options.styles.popupBackgroundColor));
55
- if (!popupProvided) {
56
- this.config.colors.popup = this.config.colors.background;
57
- }
58
-
59
- this.state = {
60
- activePopup: null,
61
- selectedOperation: '+',
62
- inputValue: ''
63
- };
64
-
65
- this.elements = {};
66
- this._render();
67
- this._updateApplyButtonState();
68
- }
69
-
70
- /**
71
- * Renders the initial toolbar UI components.
72
- * @private
73
- */
74
- _render() {
75
- this.elements.toolbarGroup = new jsvgGroup();
76
- this.parentContainer.addChild(this.elements.toolbarGroup);
77
- if (this.config.x || this.config.y) {
78
- this.elements.toolbarGroup.setPosition(this.config.x || 0, this.config.y || 0);
79
- }
80
- this.elements.toolbarGroup.svgObject.style.userSelect = 'none';
81
-
82
- this.elements.background = new jsvgRect();
83
- this.elements.background.setWidthAndHeight(362, this.config.height);
84
- this.elements.background.setCornerRadius(this.config.borderRadius);
85
- this.elements.background.setFillColor(this.config.colors.background);
86
- this.elements.toolbarGroup.addChild(this.elements.background);
87
-
88
- this.elements.leftButton = this._createButton({
89
- text: this.state.selectedOperation,
90
- callback: () => this._togglePopup('operations')
91
- });
92
- this.elements.toolbarGroup.addChild(this.elements.leftButton);
93
-
94
- this.elements.middleInputButton = this._createButton({
95
- width: this.config.inputWidth,
96
- text: this.state.inputValue,
97
- fontSize: this.config.inputFontSize,
98
- cornerRadius: 10,
99
- callback: () => this._togglePopup('input')
100
- });
101
- this.elements.toolbarGroup.addChild(this.elements.middleInputButton);
102
-
103
- const checkmarkSVG = `<svg width="43" height="33" viewBox="0 0 43 33" xmlns="http://www.w3.org/2000/svg"><rect x="9.86" y="28.63" width="40.04" height="5.74" transform="rotate(-45 9.86 28.63)" fill="black"/><rect x="13.9" y="32.69" width="19.64" height="5.74" transform="rotate(-135 13.9 32.69)" fill="black"/></svg>`;
104
- this.elements.rightButton = this._createButton({
105
- svg: checkmarkSVG,
106
- callback: () => this._applyOperation()
107
- });
108
- this.elements.toolbarGroup.addChild(this.elements.rightButton);
109
-
110
- if (this.config.showUndoButton) {
111
- this.elements.undoButton = this._createButton({
112
- size: this.config.buttonSize,
113
- iconUrl: this.config.undoIconUrl,
114
- callback: () => this._handleUndo()
115
- });
116
- // Set the circular fill color to requested green
117
- this.elements.undoButton.setFillColor(this.config.colors.undo || '#87D143');
118
- this.elements.toolbarGroup.addChild(this.elements.undoButton);
119
- }
120
-
121
- this._updateToolbarLayout();
122
- }
123
-
124
- /**
125
- * Moves an SVG element to the top of its parent's stacking order.
126
- * @param {jsvgObject|undefined} node
127
- * @private
128
- */
129
- _bringToFront(node) {
130
- try {
131
- const el = node?.svgObject;
132
- const parent = el?.parentNode;
133
- if (el && parent) {
134
- parent.appendChild(el);
135
- }
136
- } catch (_) { /* no-op */ }
137
- }
138
-
139
- /**
140
- * Toggles the visibility of a popup menu (operations or input).
141
- * Ensures only one popup is visible at a time.
142
- * @param {string} popupType - The type of popup to toggle ('operations' or 'input').
143
- * @private
144
- */
145
- _togglePopup(popupType) {
146
- if (this.state.activePopup && this.state.activePopup.type === popupType) {
147
- // Remove from toolbar group to keep transform context consistent
148
- this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
149
- this.state.activePopup = null;
150
- return;
151
- }
152
-
153
- if (this.state.activePopup) {
154
- this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
155
- this.state.activePopup = null;
156
- }
157
-
158
- let popupGroup;
159
- if (popupType === 'operations') {
160
- popupGroup = this._renderOperationsMenu();
161
- } else if (popupType === 'input') {
162
- popupGroup = this.state.selectedOperation === 'f'
163
- ? this._renderFunctionMenu()
164
- : this._renderDigitGrid();
165
- }
166
-
167
- if (popupGroup) {
168
- // Attach to toolbar group so it inherits toolbar counter-scaling (keeps constant on-screen size)
169
- this.elements.toolbarGroup.addChild(popupGroup);
170
- this.state.activePopup = { type: popupType, group: popupGroup };
171
- // Ensure the toolbar and popup are on top of all siblings inside the SVG
172
- this._bringToFront(this.elements.toolbarGroup);
173
- this._bringToFront(popupGroup);
174
- }
175
- }
176
-
177
- /**
178
- * Creates and positions a generic popup group.
179
- * @param {Function} contentFactory - A function that returns the jsvgGroup content for the popup.
180
- * @param {jsvgGroup} anchorButton - The button to anchor the popup to.
181
- * @returns {jsvgGroup} The fully rendered and positioned popup group.
182
- * @private
183
- */
184
- _renderPopup(contentFactory, anchorButton) {
185
- const popupGroup = new jsvgGroup();
186
- // Ensure popup captures interactions and overlays content
187
- if (popupGroup.svgObject) {
188
- popupGroup.svgObject.style.pointerEvents = 'auto';
189
- }
190
- const content = contentFactory();
191
-
192
- const bgWidth = content.width + 16;
193
- const bgHeight = content.height + 16;
194
-
195
- const background = new jsvgRect();
196
- background.setWidthAndHeight(bgWidth, bgHeight);
197
- background.setCornerRadius(this.config.borderRadius);
198
- background.setFillColor(this.config.colors.popup);
199
- popupGroup.addChild(background);
200
-
201
- content.setPosition(8, 8);
202
- popupGroup.addChild(content);
203
- popupGroup.width = bgWidth;
204
- popupGroup.height = bgHeight;
205
-
206
- // Anchor centered horizontally on the button, in toolbar-local coordinates
207
- const popupX = anchorButton.xpos + (anchorButton.width / 2) - (bgWidth / 2);
208
-
209
- // Determine vertical placement based on configuration (toolbar-local coordinates)
210
- const placeAbove = String(this.config.popupDirection || 'below') === 'above';
211
- const popupY = placeAbove
212
- ? (anchorButton.ypos) - bgHeight - this.config.spacing
213
- : (anchorButton.ypos + anchorButton.height + this.config.spacing);
214
- popupGroup.setPosition(Math.round(popupX), Math.round(popupY));
215
-
216
- return popupGroup;
217
- }
218
-
219
- /**
220
- * Renders the operations menu popup ('f', '÷', '×', '–', '+').
221
- * @returns {jsvgGroup} The rendered operations menu group.
222
- * @private
223
- */
224
- _renderOperationsMenu() {
225
- const operations = ['f', '÷', '×', '–', '+'];
226
- return this._renderPopup(() => {
227
- const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
228
- operations.forEach(op => {
229
- const button = this._createButton({ text: op, fontSize: this.config.menuFontSize, callback: () => this._selectOperation(op) });
230
- layout.addChild(button);
231
- });
232
- layout.doVerticalLayout();
233
- return layout;
234
- }, this.elements.leftButton);
235
- }
236
-
237
- /**
238
- * Renders the function selection menu popup ('sqrt', 'cos', etc.).
239
- * @returns {jsvgGroup} The rendered function menu group.
240
- * @private
241
- */
242
- _renderFunctionMenu() {
243
- const functions = ['sqrt', 'cos', 'sin', 'tan', 'ln'];
244
- return this._renderPopup(() => {
245
- const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
246
- functions.forEach(func => {
247
- const button = this._createButton({
248
- width: 80, height: 48, cornerRadius: 10, text: func,
249
- fontSize: this.config.inputFontSize, callback: () => this._handleFunctionClick(func)
250
- });
251
- layout.addChild(button);
252
- });
253
- layout.doVerticalLayout();
254
- return layout;
255
- }, this.elements.middleInputButton);
256
- }
257
-
258
- /**
259
- * Renders the digit grid (number pad) popup.
260
- * @returns {jsvgGroup} The rendered digit grid group.
261
- * @private
262
- */
263
- _renderDigitGrid() {
264
- const digits = [ ['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['←', '0', 'x'] ];
265
- return this._renderPopup(() => {
266
- const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
267
- digits.forEach(rowItems => {
268
- const rowGroup = new jsvgLayoutGroup({ spacer: this.config.spacing });
269
- rowItems.forEach(digit => {
270
- const button = this._createButton({ text: digit, fontSize: this.config.inputFontSize, callback: () => this._handleDigitClick(digit) });
271
- rowGroup.addChild(button);
272
- });
273
- rowGroup.doHorizontalLayout();
274
- layout.addChild(rowGroup);
275
- });
276
- layout.doVerticalLayout();
277
- return layout;
278
- }, this.elements.middleInputButton);
279
- }
280
-
281
- /**
282
- * Handles clicks on the function menu buttons.
283
- * @param {string} func - The name of the function that was clicked.
284
- * @private
285
- */
286
- _handleFunctionClick(func) {
287
- this.setInputText(func);
288
- this._togglePopup('input');
289
- }
290
-
291
- /**
292
- * Handles clicks on the digit grid buttons.
293
- * @param {string} digit - The digit or action ('←') that was clicked.
294
- * @private
295
- */
296
- _handleDigitClick(digit) {
297
- if (digit === '←') {
298
- this.state.inputValue = this.state.inputValue.slice(0, -1);
299
- } else {
300
- this.state.inputValue += digit;
301
- }
302
- this.setInputText(this.state.inputValue);
303
- }
304
-
305
- /**
306
- * Sets the text of the middle input button.
307
- * @param {string} text - The text to display.
308
- */
309
- setInputText(text) {
310
- this.state.inputValue = text;
311
- const button = this.elements.middleInputButton;
312
- button.setText(text);
313
-
314
- // Get the button's text element and set font size
315
- const textElement = button.buttonText;
316
- textElement.setFontSize(this.config.inputFontSize);
317
-
318
- this._updateApplyButtonState();
319
- }
320
-
321
- /**
322
- * Handles the selection of a new operation from the menu.
323
- * @param {string} operation - The selected operation symbol.
324
- * @private
325
- */
326
- _selectOperation(operation) {
327
- // Clear input text when switching to or from function mode.
328
- if (this.state.selectedOperation === 'f' || operation === 'f') {
329
- this.setInputText('');
330
- }
331
-
332
- this.state.selectedOperation = operation;
333
- this.elements.leftButton.setText(operation);
334
- this._togglePopup('operations');
335
-
336
- // If we switched to function mode and the input popup was open, refresh it.
337
- if (operation === 'f' && this.state.activePopup?.type === 'input') {
338
- this._togglePopup('input'); // Close number pad
339
- this._togglePopup('input'); // Open function menu
340
- }
341
- }
342
-
343
- /**
344
- * Applies the selected operation and value to the sequence.
345
- * @private
346
- */
347
- _applyOperation() {
348
- const op = this.state.selectedOperation;
349
- const val = this.state.inputValue;
350
-
351
- if (!this.sequence || val === '') return;
352
-
353
- if (op === 'f') {
354
- this.sequence.applyEquationFunction(val);
355
- } else {
356
- const operationMap = {
357
- '÷': 'divide',
358
- '×': 'multiply',
359
- '–': 'subtract',
360
- '+': 'add'
361
- };
362
- const operationName = operationMap[op];
363
- let valueToApply;
364
- let isValid = false;
365
- // Try to parse as number first
366
- const numericValue = parseFloat(val);
367
- if (!isNaN(numericValue) && String(numericValue) === val.trim()) {
368
- valueToApply = numericValue;
369
- isValid = true;
370
- } else if (typeof window.math !== 'undefined' && window.math.parse) {
371
- try {
372
- valueToApply = window.math.parse(val);
373
- isValid = true;
374
- } catch (e) {
375
- isValid = false;
376
- }
377
- }
378
- if (operationName && isValid) {
379
- this.sequence.applyEquationOperation(valueToApply, operationName);
380
- }
381
- }
382
-
383
- // Clear the input after applying the operation
384
- this.setInputText('');
385
-
386
- if (this.state.activePopup) {
387
- // Remove from toolbar group where it was attached
388
- this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
389
- this.state.activePopup = null;
390
- }
391
-
392
- // Notify host to refresh display and any active external visualizations
393
- try {
394
- if (typeof window !== 'undefined') {
395
- if (typeof window.refreshDisplayAndFilters === 'function') {
396
- window.refreshDisplayAndFilters();
397
- }
398
- if (typeof window.onOMDOperationApplied === 'function') {
399
- window.onOMDOperationApplied(this.sequence);
400
- }
401
- }
402
- } catch (_) { /* no-op */ }
403
- }
404
-
405
- /**
406
- * Creates a button component with the specified configuration.
407
- * @param {object} config - The button configuration.
408
- * @param {number} [config.width] - The width of the button.
409
- * @param {number} [config.height] - The height of the button.
410
- * @param {number} [config.size] - The size for both width and height.
411
- * @param {string} [config.text] - The text label for the button.
412
- * @param {string} [config.svg] - The SVG content for the button icon.
413
- * @param {number} [config.fontSize] - The font size for the text.
414
- * @param {number} [config.cornerRadius] - The corner radius of the button.
415
- * @param {Function} config.callback - The function to call on click.
416
- * @returns {jsvgButton} The created button.
417
- * @private
418
- */
419
- _createButton({ width, height, size, text, svg, iconUrl, fontSize, cornerRadius, callback }) {
420
- const button = new jsvgButton();
421
- const w = width || size || this.config.buttonSize;
422
- const h = height || size || this.config.buttonSize;
423
- button.setWidthAndHeight(w, h);
424
- button.setCornerRadius(cornerRadius !== undefined ? cornerRadius : w / 2);
425
- button.setFillColor(this.config.colors.button);
426
- button.setText(text || '');
427
- button.setFontSize(fontSize || this.config.mainFontSize);
428
- button.setFontFamily(this.config.fontFamily);
429
- button.buttonText.setFontWeight(this.config.fontWeight);
430
-
431
- // Adjust vertical position of text for better centering
432
- button.buttonText.setPosition(w/2, h/2 + (fontSize || this.config.mainFontSize)/3);
433
-
434
- if (svg) {
435
- const dataURI = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
436
- button.addImage(dataURI, this.config.checkMarkSize, this.config.checkMarkSize);
437
- } else if (iconUrl) {
438
- // Use default icon size (same as checkmark) centered inside the circular button
439
- const sz = this.config.checkMarkSize;
440
- button.addImage(iconUrl, sz, sz);
441
- }
442
-
443
- button.setClickCallback(callback);
444
- return button;
445
- }
446
-
447
- /**
448
- * Updates the enabled/disabled state of the apply button.
449
- * @private
450
- */
451
- _updateApplyButtonState() {
452
- const button = this.elements.rightButton;
453
- const hasValue = this.state.inputValue.length > 0;
454
-
455
- if (hasValue) {
456
- button.setOpacity(1.0);
457
- button.setClickCallback(() => this._applyOperation());
458
- } else {
459
- button.setOpacity(0.5);
460
- button.setClickCallback(null);
461
- }
462
- }
463
-
464
- /**
465
- * Updates the positions of the toolbar elements.
466
- * @private
467
- */
468
- _updateToolbarLayout() {
469
- const totalWidth = this.config.buttonSize * 2 + this.config.inputWidth + this.config.spacing * 2 + this.config.padding * 2;
470
- this.elements.background.setWidth(totalWidth);
471
-
472
- const yPos = this.config.padding;
473
- let xPos = this.config.padding;
474
-
475
- this.elements.leftButton.setPosition(xPos, yPos);
476
- xPos += this.elements.leftButton.width + this.config.spacing;
477
-
478
- this.elements.middleInputButton.setPosition(xPos, yPos);
479
- xPos += this.elements.middleInputButton.width + this.config.spacing;
480
-
481
- this.elements.rightButton.setPosition(xPos, yPos);
482
-
483
- // Position optional undo button directly to the right of the toolbar background
484
- if (this.elements.undoButton) {
485
- const undoX = this.elements.background.width + this.config.spacing;
486
- this.elements.undoButton.setPosition(undoX, yPos);
487
- }
488
- }
489
-
490
- _handleUndo() {
491
- if (typeof this.config.onUndo === 'function') {
492
- try { this.config.onUndo(this.sequence); } catch (_) {}
493
- return;
494
- }
495
- // Fallback: emit a global hook
496
- try {
497
- if (typeof window !== 'undefined' && typeof window.onOMDToolbarUndo === 'function') {
498
- window.onOMDToolbarUndo(this.sequence);
499
- }
500
- } catch (_) { /* no-op */ }
501
- }
1
+ import { omdEquationSequenceNode } from '../nodes/omdEquationSequenceNode.js';
2
+ import { omdEquationNode } from '../nodes/omdEquationNode.js';
3
+ import { omdColor } from '../../src/omdColor.js';
4
+ import { jsvgGroup, jsvgRect, jsvgLayoutGroup, jsvgButton } from '@teachinglab/jsvg';
5
+
6
+ /**
7
+ * A toolbar component for applying mathematical operations to an omdEquationSequenceNode.
8
+ */
9
+ export class omdToolbar {
10
+ /**
11
+ * Creates an instance of the omdToolbar.
12
+ * @param {jsvgGroup} parentContainer - The parent SVG group to render the toolbar into.
13
+ * @param {omdEquationSequenceNode} sequence - The sequence node to apply operations to.
14
+ * @param {object} [options={}] - Configuration options for the toolbar.
15
+ */
16
+ constructor(parentContainer, sequence, options = {}) {
17
+ this.parentContainer = parentContainer;
18
+ this.sequence = sequence;
19
+
20
+ this.config = {
21
+ height: 60, padding: 6, spacing: 8, borderRadius: 30,
22
+ fontFamily: "'Albert Sans', sans-serif", fontWeight: '500',
23
+ colors: { background: omdColor.mediumGray, button: 'white', popup: omdColor.lightGray, undo: '#87D143' },
24
+ buttonSize: 48, checkMarkSize: 24, mainFontSize: 32,
25
+ inputFontSize: 28, menuFontSize: 24, inputWidth: 120,
26
+ popupDirection: 'below',
27
+ showUndoButton: false,
28
+ undoIconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzciIGhlaWdodD0iNDQiIHZpZXdCb3g9IjAgMCAzNyA0NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4NCjxwYXRoIGQ9Ik0xOC4zNjc2IDQzLjgzMDFDMTMuNTkxMyA0My44MjM3IDkuMDEyNTYgNDEuOTIzNCA1LjYzNTI1IDM4LjU0NTlDMi4yNTc5MiAzNS4xNjg1IDAuMzU3NjYgMzAuNTg5OSAwLjM1MTA3NCAyNS44MTM2QzAuMzUxMDc0IDI1LjMxOTQgMC41NDc0NDEgMjQuODQ1MiAwLjg5Njk2MSAyNC40OTU4QzEuMjQ2NDggMjQuMTQ2MiAxLjcyMDU1IDIzLjk0OTggMi4yMTQ4NSAyMy45NDk4QzIuNzA5MTUgMjMuOTQ5OCAzLjE4MzIyIDI0LjE0NjIgMy41MzI3NCAyNC40OTU4QzMuODgyMjYgMjQuODQ1MiA0LjA3ODYzIDI1LjMxOTQgNC4wNzg2MyAyNS44MTM2QzQuMDc4NjMgMjguNjM5NiA0LjkxNjY2IDMxLjQwMjIgNi40ODY3NSAzMy43NTIxQzguMDU2ODUgMzYuMTAxOSAxMC4yODg1IDM3LjkzMzQgMTIuODk5NCAzOS4wMTQ5QzE1LjUxMDMgNDAuMDk2NCAxOC4zODM1IDQwLjM3OTQgMjEuMTU1MyAzOS44MjhDMjMuOTI3MSAzOS4yNzY4IDI2LjQ3MyAzNy45MTU3IDI4LjQ3MTUgMzUuOTE3NUMzMC40Njk3IDMzLjkxOTEgMzEuODMwOCAzMS4zNzMxIDMyLjM4MTkgMjguNjAxM0MzMi45MzM0IDI1LjgyOTUgMzIuNjUwMyAyMi45NTYzIDMxLjU2ODggMjAuMzQ1NkMzMC40ODczIDE3LjczNDUgMjguNjU1OSAxNS41MDI5IDI2LjMwNiAxMy45MzI4QzIzLjk1NjIgMTIuMzYyNyAyMS4xOTM2IDExLjUyNDcgMTguMzY3NiAxMS41MjQ3SDEyLjE1NUMxMS42NjA3IDExLjUyNDcgMTEuMTg2NiAxMS4zMjgzIDEwLjgzNzEgMTAuOTc4OEMxMC40ODc2IDEwLjYyOTMgMTAuMjkxMiAxMC4xNTUyIDEwLjI5MTIgOS42NjA5QzEwLjI5MTIgOS4xNjY2IDEwLjQ4NzYgOC42OTI1MyAxMC44MzcxIDguMzQzMDFDMTEuMTg2NiA3Ljk5MzQ5IDExLjY2MDcgNy43OTcxMiAxMi4xNTUgNy43OTcxMkgxOC4zNjc2QzIzLjE0NTggNy43OTcxMiAyNy43Mjg1IDkuNjk1MjkgMzEuMTA3MSAxMy4wNzRDMzQuNDg2IDE2LjQ1MjggMzYuMzg0MSAyMS4wMzU0IDM2LjM4NDEgMjUuODEzNkMzNi4zODQxIDMwLjU5MTkgMzQuNDg2IDM1LjE3NDUgMzEuMTA3MSAzOC41NTMyQzI3LjcyODUgNDEuOTMyMSAyMy4xNDU4IDQzLjgzMDEgMTguMzY3NiA0My44MzAxWiIgZmlsbD0id2hpdGUiLz4NCjxwYXRoIGQ9Ik0xOC4zNjc1IDE4Ljk3OThDMTguMTIyNyAxOC45ODEgMTcuODc5OSAxOC45MzMzIDE3LjY1MzggMTguODM5NEMxNy40Mjc3IDE4Ljc0NTQgMTcuMjIyNyAxOC42MDczIDE3LjA1MDQgMTguNDMzMUw5LjU5NTM2IDEwLjk3OEM5LjI0NjM0IDEwLjYyODYgOS4wNTAyOSAxMC4xNTQ5IDkuMDUwMjkgOS42NjA5NkM5LjA1MDI5IDkuMTY3MDYgOS4yNDYzNCA4LjY5MzM2IDkuNTk1MzYgOC4zNDM4OUwxNy4wNTA0IDAuODg4Nzg5QzE3LjIyMTIgMC43MDU2NjcgMTcuNDI2OSAwLjU1ODgwMSAxNy42NTU1IDAuNDU2OTM5QzE3Ljg4NDIgMC4zNTUwNzggMTguMTMwOSAwLjMwMDMwOCAxOC4zODEyIDAuMjk1ODg0QzE4LjYzMTQgMC4yOTE0NjEgMTguODc5OSAwLjMzNzUwOCAxOS4xMTIgMC40MzEyNDRDMTkuMzQ0MSAwLjUyNDk3OSAxOS41NTQ5IDAuNjY0NDg5IDE5LjczMTggMC44NDE0NzNDMTkuOTA5IDEuMDE4NDYgMjAuMDQ4NCAxLjIyOTI5IDIwLjE0MjEgMS40NjEzNEMyMC4yMzYgMS42OTM0MiAyMC4yODIgMS45NDIgMjAuMjc3NSAyLjE5MjI0QzIwLjI3MyAyLjQ0MjUxIDIwLjIxODQgMi42ODkzIDIwLjExNjUgMi45MTc5MkMyMC4wMTQ2IDMuMTQ2NTQgMTkuODY3NyAzLjM1MjMgMTkuNjg0NiAzLjUyMjkzTDEzLjU0NjUgOS42NjA5NkwxOS42ODQ2IDE1Ljc5OUMyMC4wMzM3IDE2LjE0ODUgMjAuMjI5OCAxNi42MjIyIDIwLjIyOTggMTcuMTE2QzIwLjIyOTggMTcuNjEgMjAuMDMzNyAxOC4wODM3IDE5LjY4NDYgMTguNDMzMUMxOS41MTI2IDE4LjYwNzMgMTkuMzA3MyAxOC43NDU0IDE5LjA4MTIgMTguODM5NEMxOC44NTUxIDE4LjkzMzMgMTguNjEyNSAxOC45ODEgMTguMzY3NSAxOC45Nzk4WiIgZmlsbD0id2hpdGUiLz4NCjwvc3ZnPg0K',
29
+ onUndo: null,
30
+ ...options
31
+ };
32
+
33
+ // Support structured styles from equation stack toolbar options
34
+ if (options.styles && typeof options.styles === 'object') {
35
+ const s = options.styles;
36
+ if (s.backgroundColor) this.config.colors.background = s.backgroundColor;
37
+ if (s.buttonColor) this.config.colors.button = s.buttonColor;
38
+ if (s.popupBackgroundColor) this.config.colors.popup = s.popupBackgroundColor;
39
+ if (typeof s.borderRadius === 'number') this.config.borderRadius = s.borderRadius;
40
+ if (typeof s.buttonSize === 'number') this.config.buttonSize = s.buttonSize;
41
+ if (typeof s.mainFontSize === 'number') this.config.mainFontSize = s.mainFontSize;
42
+ if (typeof s.inputFontSize === 'number') this.config.inputFontSize = s.inputFontSize;
43
+ if (typeof s.menuFontSize === 'number') this.config.menuFontSize = s.menuFontSize;
44
+ if (typeof s.inputWidth === 'number') this.config.inputWidth = s.inputWidth;
45
+ if (typeof s.padding === 'number') this.config.padding = s.padding;
46
+ if (typeof s.spacing === 'number') this.config.spacing = s.spacing;
47
+ }
48
+
49
+ // Simple aliases remain supported
50
+ if (options.backgroundColor) this.config.colors.background = options.backgroundColor;
51
+ if (options.popupBackgroundColor) this.config.colors.popup = options.popupBackgroundColor;
52
+
53
+ // If no explicit popup color was provided, default it to the toolbar background color
54
+ const popupProvided = !!(options.popupBackgroundColor || (options.styles && options.styles.popupBackgroundColor));
55
+ if (!popupProvided) {
56
+ this.config.colors.popup = this.config.colors.background;
57
+ }
58
+
59
+ this.state = {
60
+ activePopup: null,
61
+ selectedOperation: '+',
62
+ inputValue: ''
63
+ };
64
+
65
+ this.elements = {};
66
+ this._render();
67
+ this._updateApplyButtonState();
68
+ }
69
+
70
+ /**
71
+ * Renders the initial toolbar UI components.
72
+ * @private
73
+ */
74
+ _render() {
75
+ this.elements.toolbarGroup = new jsvgGroup();
76
+ this.parentContainer.addChild(this.elements.toolbarGroup);
77
+ if (this.config.x || this.config.y) {
78
+ this.elements.toolbarGroup.setPosition(this.config.x || 0, this.config.y || 0);
79
+ }
80
+ this.elements.toolbarGroup.svgObject.style.userSelect = 'none';
81
+
82
+ this.elements.background = new jsvgRect();
83
+ this.elements.background.setWidthAndHeight(362, this.config.height);
84
+ this.elements.background.setCornerRadius(this.config.borderRadius);
85
+ this.elements.background.setFillColor(this.config.colors.background);
86
+ this.elements.toolbarGroup.addChild(this.elements.background);
87
+
88
+ this.elements.leftButton = this._createButton({
89
+ text: this.state.selectedOperation,
90
+ callback: () => this._togglePopup('operations')
91
+ });
92
+ this.elements.toolbarGroup.addChild(this.elements.leftButton);
93
+
94
+ this.elements.middleInputButton = this._createButton({
95
+ width: this.config.inputWidth,
96
+ text: this.state.inputValue,
97
+ fontSize: this.config.inputFontSize,
98
+ cornerRadius: 10,
99
+ callback: () => this._togglePopup('input')
100
+ });
101
+ this.elements.toolbarGroup.addChild(this.elements.middleInputButton);
102
+
103
+ const checkmarkSVG = `<svg width="43" height="33" viewBox="0 0 43 33" xmlns="http://www.w3.org/2000/svg"><rect x="9.86" y="28.63" width="40.04" height="5.74" transform="rotate(-45 9.86 28.63)" fill="black"/><rect x="13.9" y="32.69" width="19.64" height="5.74" transform="rotate(-135 13.9 32.69)" fill="black"/></svg>`;
104
+ this.elements.rightButton = this._createButton({
105
+ svg: checkmarkSVG,
106
+ callback: () => this._applyOperation()
107
+ });
108
+ this.elements.toolbarGroup.addChild(this.elements.rightButton);
109
+
110
+ if (this.config.showUndoButton) {
111
+ this.elements.undoButton = this._createButton({
112
+ size: this.config.buttonSize,
113
+ iconUrl: this.config.undoIconUrl,
114
+ callback: () => this._handleUndo()
115
+ });
116
+ // Set the circular fill color to requested green
117
+ this.elements.undoButton.setFillColor(this.config.colors.undo || '#87D143');
118
+ this.elements.toolbarGroup.addChild(this.elements.undoButton);
119
+ }
120
+
121
+ this._updateToolbarLayout();
122
+ }
123
+
124
+ /**
125
+ * Moves an SVG element to the top of its parent's stacking order.
126
+ * @param {jsvgObject|undefined} node
127
+ * @private
128
+ */
129
+ _bringToFront(node) {
130
+ try {
131
+ const el = node?.svgObject;
132
+ const parent = el?.parentNode;
133
+ if (el && parent) {
134
+ parent.appendChild(el);
135
+ }
136
+ } catch (_) { /* no-op */ }
137
+ }
138
+
139
+ /**
140
+ * Toggles the visibility of a popup menu (operations or input).
141
+ * Ensures only one popup is visible at a time.
142
+ * @param {string} popupType - The type of popup to toggle ('operations' or 'input').
143
+ * @private
144
+ */
145
+ _togglePopup(popupType) {
146
+ if (this.state.activePopup && this.state.activePopup.type === popupType) {
147
+ // Remove from toolbar group to keep transform context consistent
148
+ this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
149
+ this.state.activePopup = null;
150
+ return;
151
+ }
152
+
153
+ if (this.state.activePopup) {
154
+ this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
155
+ this.state.activePopup = null;
156
+ }
157
+
158
+ let popupGroup;
159
+ if (popupType === 'operations') {
160
+ popupGroup = this._renderOperationsMenu();
161
+ } else if (popupType === 'input') {
162
+ popupGroup = this.state.selectedOperation === 'f'
163
+ ? this._renderFunctionMenu()
164
+ : this._renderDigitGrid();
165
+ }
166
+
167
+ if (popupGroup) {
168
+ // Attach to toolbar group so it inherits toolbar counter-scaling (keeps constant on-screen size)
169
+ this.elements.toolbarGroup.addChild(popupGroup);
170
+ this.state.activePopup = { type: popupType, group: popupGroup };
171
+ // Ensure the toolbar and popup are on top of all siblings inside the SVG
172
+ this._bringToFront(this.elements.toolbarGroup);
173
+ this._bringToFront(popupGroup);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Creates and positions a generic popup group.
179
+ * @param {Function} contentFactory - A function that returns the jsvgGroup content for the popup.
180
+ * @param {jsvgGroup} anchorButton - The button to anchor the popup to.
181
+ * @returns {jsvgGroup} The fully rendered and positioned popup group.
182
+ * @private
183
+ */
184
+ _renderPopup(contentFactory, anchorButton) {
185
+ const popupGroup = new jsvgGroup();
186
+ // Ensure popup captures interactions and overlays content
187
+ if (popupGroup.svgObject) {
188
+ popupGroup.svgObject.style.pointerEvents = 'auto';
189
+ }
190
+ const content = contentFactory();
191
+
192
+ const bgWidth = content.width + 16;
193
+ const bgHeight = content.height + 16;
194
+
195
+ const background = new jsvgRect();
196
+ background.setWidthAndHeight(bgWidth, bgHeight);
197
+ background.setCornerRadius(this.config.borderRadius);
198
+ background.setFillColor(this.config.colors.popup);
199
+ popupGroup.addChild(background);
200
+
201
+ content.setPosition(8, 8);
202
+ popupGroup.addChild(content);
203
+ popupGroup.width = bgWidth;
204
+ popupGroup.height = bgHeight;
205
+
206
+ // Anchor centered horizontally on the button, in toolbar-local coordinates
207
+ const popupX = anchorButton.xpos + (anchorButton.width / 2) - (bgWidth / 2);
208
+
209
+ // Determine vertical placement based on configuration (toolbar-local coordinates)
210
+ const placeAbove = String(this.config.popupDirection || 'below') === 'above';
211
+ const popupY = placeAbove
212
+ ? (anchorButton.ypos) - bgHeight - this.config.spacing
213
+ : (anchorButton.ypos + anchorButton.height + this.config.spacing);
214
+ popupGroup.setPosition(Math.round(popupX), Math.round(popupY));
215
+
216
+ return popupGroup;
217
+ }
218
+
219
+ /**
220
+ * Renders the operations menu popup ('f', '÷', '×', '–', '+').
221
+ * @returns {jsvgGroup} The rendered operations menu group.
222
+ * @private
223
+ */
224
+ _renderOperationsMenu() {
225
+ const operations = ['f', '÷', '×', '–', '+'];
226
+ return this._renderPopup(() => {
227
+ const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
228
+ operations.forEach(op => {
229
+ const button = this._createButton({ text: op, fontSize: this.config.menuFontSize, callback: () => this._selectOperation(op) });
230
+ layout.addChild(button);
231
+ });
232
+ layout.doVerticalLayout();
233
+ return layout;
234
+ }, this.elements.leftButton);
235
+ }
236
+
237
+ /**
238
+ * Renders the function selection menu popup ('sqrt', 'cos', etc.).
239
+ * @returns {jsvgGroup} The rendered function menu group.
240
+ * @private
241
+ */
242
+ _renderFunctionMenu() {
243
+ const functions = ['sqrt', 'cos', 'sin', 'tan', 'ln'];
244
+ return this._renderPopup(() => {
245
+ const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
246
+ functions.forEach(func => {
247
+ const button = this._createButton({
248
+ width: 80, height: 48, cornerRadius: 10, text: func,
249
+ fontSize: this.config.inputFontSize, callback: () => this._handleFunctionClick(func)
250
+ });
251
+ layout.addChild(button);
252
+ });
253
+ layout.doVerticalLayout();
254
+ return layout;
255
+ }, this.elements.middleInputButton);
256
+ }
257
+
258
+ /**
259
+ * Renders the digit grid (number pad) popup.
260
+ * @returns {jsvgGroup} The rendered digit grid group.
261
+ * @private
262
+ */
263
+ _renderDigitGrid() {
264
+ const digits = [ ['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['←', '0', 'x'] ];
265
+ return this._renderPopup(() => {
266
+ const layout = new jsvgLayoutGroup({ spacer: this.config.spacing });
267
+ digits.forEach(rowItems => {
268
+ const rowGroup = new jsvgLayoutGroup({ spacer: this.config.spacing });
269
+ rowItems.forEach(digit => {
270
+ const button = this._createButton({ text: digit, fontSize: this.config.inputFontSize, callback: () => this._handleDigitClick(digit) });
271
+ rowGroup.addChild(button);
272
+ });
273
+ rowGroup.doHorizontalLayout();
274
+ layout.addChild(rowGroup);
275
+ });
276
+ layout.doVerticalLayout();
277
+ return layout;
278
+ }, this.elements.middleInputButton);
279
+ }
280
+
281
+ /**
282
+ * Handles clicks on the function menu buttons.
283
+ * @param {string} func - The name of the function that was clicked.
284
+ * @private
285
+ */
286
+ _handleFunctionClick(func) {
287
+ this.setInputText(func);
288
+ this._togglePopup('input');
289
+ }
290
+
291
+ /**
292
+ * Handles clicks on the digit grid buttons.
293
+ * @param {string} digit - The digit or action ('←') that was clicked.
294
+ * @private
295
+ */
296
+ _handleDigitClick(digit) {
297
+ if (digit === '←') {
298
+ this.state.inputValue = this.state.inputValue.slice(0, -1);
299
+ } else {
300
+ this.state.inputValue += digit;
301
+ }
302
+ this.setInputText(this.state.inputValue);
303
+ }
304
+
305
+ /**
306
+ * Sets the text of the middle input button.
307
+ * @param {string} text - The text to display.
308
+ */
309
+ setInputText(text) {
310
+ this.state.inputValue = text;
311
+ const button = this.elements.middleInputButton;
312
+ button.setText(text);
313
+
314
+ // Get the button's text element and set font size
315
+ const textElement = button.buttonText;
316
+ textElement.setFontSize(this.config.inputFontSize);
317
+
318
+ this._updateApplyButtonState();
319
+ }
320
+
321
+ /**
322
+ * Handles the selection of a new operation from the menu.
323
+ * @param {string} operation - The selected operation symbol.
324
+ * @private
325
+ */
326
+ _selectOperation(operation) {
327
+ // Clear input text when switching to or from function mode.
328
+ if (this.state.selectedOperation === 'f' || operation === 'f') {
329
+ this.setInputText('');
330
+ }
331
+
332
+ this.state.selectedOperation = operation;
333
+ this.elements.leftButton.setText(operation);
334
+ this._togglePopup('operations');
335
+
336
+ // If we switched to function mode and the input popup was open, refresh it.
337
+ if (operation === 'f' && this.state.activePopup?.type === 'input') {
338
+ this._togglePopup('input'); // Close number pad
339
+ this._togglePopup('input'); // Open function menu
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Applies the selected operation and value to the sequence.
345
+ * @private
346
+ */
347
+ _applyOperation() {
348
+ const op = this.state.selectedOperation;
349
+ const val = this.state.inputValue;
350
+
351
+ if (!this.sequence || val === '') return;
352
+
353
+ if (op === 'f') {
354
+ this.sequence.applyEquationFunction(val);
355
+ } else {
356
+ const operationMap = {
357
+ '÷': 'divide',
358
+ '×': 'multiply',
359
+ '–': 'subtract',
360
+ '+': 'add'
361
+ };
362
+ const operationName = operationMap[op];
363
+ let valueToApply;
364
+ let isValid = false;
365
+ // Try to parse as number first
366
+ const numericValue = parseFloat(val);
367
+ if (!isNaN(numericValue) && String(numericValue) === val.trim()) {
368
+ valueToApply = numericValue;
369
+ isValid = true;
370
+ } else if (typeof window.math !== 'undefined' && window.math.parse) {
371
+ try {
372
+ valueToApply = window.math.parse(val);
373
+ isValid = true;
374
+ } catch (e) {
375
+ isValid = false;
376
+ }
377
+ }
378
+ if (operationName && isValid) {
379
+ this.sequence.applyEquationOperation(valueToApply, operationName);
380
+ }
381
+ }
382
+
383
+ // Clear the input after applying the operation
384
+ this.setInputText('');
385
+
386
+ if (this.state.activePopup) {
387
+ // Remove from toolbar group where it was attached
388
+ this.elements.toolbarGroup.removeChild(this.state.activePopup.group);
389
+ this.state.activePopup = null;
390
+ }
391
+
392
+ // Notify host to refresh display and any active external visualizations
393
+ try {
394
+ if (typeof window !== 'undefined') {
395
+ if (typeof window.refreshDisplayAndFilters === 'function') {
396
+ window.refreshDisplayAndFilters();
397
+ }
398
+ if (typeof window.onOMDOperationApplied === 'function') {
399
+ window.onOMDOperationApplied(this.sequence);
400
+ }
401
+ }
402
+ } catch (_) { /* no-op */ }
403
+ }
404
+
405
+ /**
406
+ * Creates a button component with the specified configuration.
407
+ * @param {object} config - The button configuration.
408
+ * @param {number} [config.width] - The width of the button.
409
+ * @param {number} [config.height] - The height of the button.
410
+ * @param {number} [config.size] - The size for both width and height.
411
+ * @param {string} [config.text] - The text label for the button.
412
+ * @param {string} [config.svg] - The SVG content for the button icon.
413
+ * @param {number} [config.fontSize] - The font size for the text.
414
+ * @param {number} [config.cornerRadius] - The corner radius of the button.
415
+ * @param {Function} config.callback - The function to call on click.
416
+ * @returns {jsvgButton} The created button.
417
+ * @private
418
+ */
419
+ _createButton({ width, height, size, text, svg, iconUrl, fontSize, cornerRadius, callback }) {
420
+ const button = new jsvgButton();
421
+ const w = width || size || this.config.buttonSize;
422
+ const h = height || size || this.config.buttonSize;
423
+ button.setWidthAndHeight(w, h);
424
+ button.setCornerRadius(cornerRadius !== undefined ? cornerRadius : w / 2);
425
+ button.setFillColor(this.config.colors.button);
426
+ button.setText(text || '');
427
+ button.setFontSize(fontSize || this.config.mainFontSize);
428
+ button.setFontFamily(this.config.fontFamily);
429
+ button.buttonText.setFontWeight(this.config.fontWeight);
430
+
431
+ // Adjust vertical position of text for better centering
432
+ button.buttonText.setPosition(w/2, h/2 + (fontSize || this.config.mainFontSize)/3);
433
+
434
+ if (svg) {
435
+ const dataURI = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
436
+ button.addImage(dataURI, this.config.checkMarkSize, this.config.checkMarkSize);
437
+ } else if (iconUrl) {
438
+ // Use default icon size (same as checkmark) centered inside the circular button
439
+ const sz = this.config.checkMarkSize;
440
+ button.addImage(iconUrl, sz, sz);
441
+ }
442
+
443
+ button.setClickCallback(callback);
444
+ return button;
445
+ }
446
+
447
+ /**
448
+ * Updates the enabled/disabled state of the apply button.
449
+ * @private
450
+ */
451
+ _updateApplyButtonState() {
452
+ const button = this.elements.rightButton;
453
+ const hasValue = this.state.inputValue.length > 0;
454
+
455
+ if (hasValue) {
456
+ button.setOpacity(1.0);
457
+ button.setClickCallback(() => this._applyOperation());
458
+ } else {
459
+ button.setOpacity(0.5);
460
+ button.setClickCallback(null);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Updates the positions of the toolbar elements.
466
+ * @private
467
+ */
468
+ _updateToolbarLayout() {
469
+ const totalWidth = this.config.buttonSize * 2 + this.config.inputWidth + this.config.spacing * 2 + this.config.padding * 2;
470
+ this.elements.background.setWidth(totalWidth);
471
+
472
+ const yPos = this.config.padding;
473
+ let xPos = this.config.padding;
474
+
475
+ this.elements.leftButton.setPosition(xPos, yPos);
476
+ xPos += this.elements.leftButton.width + this.config.spacing;
477
+
478
+ this.elements.middleInputButton.setPosition(xPos, yPos);
479
+ xPos += this.elements.middleInputButton.width + this.config.spacing;
480
+
481
+ this.elements.rightButton.setPosition(xPos, yPos);
482
+
483
+ // Position optional undo button directly to the right of the toolbar background
484
+ if (this.elements.undoButton) {
485
+ const undoX = this.elements.background.width + this.config.spacing;
486
+ this.elements.undoButton.setPosition(undoX, yPos);
487
+ }
488
+ }
489
+
490
+ _handleUndo() {
491
+ if (typeof this.config.onUndo === 'function') {
492
+ try { this.config.onUndo(this.sequence); } catch (_) {}
493
+ return;
494
+ }
495
+ // Fallback: emit a global hook
496
+ try {
497
+ if (typeof window !== 'undefined' && typeof window.onOMDToolbarUndo === 'function') {
498
+ window.onOMDToolbarUndo(this.sequence);
499
+ }
500
+ } catch (_) { /* no-op */ }
501
+ }
502
502
  }