@ngxsmk/cdk 1.3.2 → 3.0.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.
Files changed (199) hide show
  1. package/README.md +1 -1
  2. package/cva-base/cva-base.d.ts +34 -0
  3. package/cva-base/cva-base.d.ts.map +1 -0
  4. package/cva-base/index.d.ts +2 -0
  5. package/cva-base/index.d.ts.map +1 -0
  6. package/cva-base/ngxsmk-cdk-cva-base.d.ts.map +1 -0
  7. package/esm2022/cva-base/cva-base.mjs +45 -0
  8. package/esm2022/cva-base/index.mjs +2 -0
  9. package/esm2022/cva-base/ngxsmk-cdk-cva-base.mjs +5 -0
  10. package/esm2022/input-group/index.mjs +3 -0
  11. package/esm2022/input-group/input-group-engine.mjs +219 -0
  12. package/esm2022/input-group/models/index.mjs +2 -0
  13. package/esm2022/input-group/models/input-group.model.mjs +2 -0
  14. package/esm2022/input-group/ngxsmk-cdk-input-group.mjs +5 -0
  15. package/esm2022/listbox-keyboard/index.mjs +2 -0
  16. package/esm2022/listbox-keyboard/listbox-keyboard.mjs +145 -0
  17. package/esm2022/listbox-keyboard/ngxsmk-cdk-listbox-keyboard.mjs +5 -0
  18. package/esm2022/public-api.mjs +10 -1
  19. package/esm2022/roving-focus/index.mjs +2 -0
  20. package/esm2022/roving-focus/ngxsmk-cdk-roving-focus.mjs +5 -0
  21. package/esm2022/roving-focus/roving-focus.mjs +176 -0
  22. package/esm2022/scheduler/index.mjs +5 -0
  23. package/esm2022/scheduler/models/date-range.mjs +2 -0
  24. package/esm2022/scheduler/models/event.model.mjs +2 -0
  25. package/esm2022/scheduler/models/index.mjs +6 -0
  26. package/esm2022/scheduler/models/resource.model.mjs +5 -0
  27. package/esm2022/scheduler/models/selection.model.mjs +7 -0
  28. package/esm2022/scheduler/models/view.model.mjs +7 -0
  29. package/esm2022/scheduler/ngxsmk-cdk-scheduler.mjs +5 -0
  30. package/esm2022/scheduler/plugin/drag-plugin.mjs +82 -0
  31. package/esm2022/scheduler/plugin/index.mjs +8 -0
  32. package/esm2022/scheduler/plugin/keyboard-plugin.mjs +150 -0
  33. package/esm2022/scheduler/plugin/plugin-host.mjs +48 -0
  34. package/esm2022/scheduler/plugin/plugin.types.mjs +2 -0
  35. package/esm2022/scheduler/plugin/resize-plugin.mjs +74 -0
  36. package/esm2022/scheduler/plugin/selection-plugin.mjs +84 -0
  37. package/esm2022/scheduler/plugin/tooltip-plugin.mjs +103 -0
  38. package/esm2022/scheduler/scheduler-engine.mjs +316 -0
  39. package/esm2022/scheduler/utils/date-utils.mjs +131 -0
  40. package/esm2022/scheduler/utils/index.mjs +3 -0
  41. package/esm2022/scheduler/utils/snap.mjs +24 -0
  42. package/esm2022/scroll-lock/scroll-lock.mjs +22 -5
  43. package/esm2022/skip-link/index.mjs +2 -0
  44. package/esm2022/skip-link/ngxsmk-cdk-skip-link.mjs +5 -0
  45. package/esm2022/skip-link/skip-link.mjs +33 -0
  46. package/esm2022/spreadsheet/index.mjs +5 -0
  47. package/esm2022/spreadsheet/models/cell.model.mjs +2 -0
  48. package/esm2022/spreadsheet/models/column.model.mjs +2 -0
  49. package/esm2022/spreadsheet/models/filter.model.mjs +2 -0
  50. package/esm2022/spreadsheet/models/formula.model.mjs +2 -0
  51. package/esm2022/spreadsheet/models/index.mjs +8 -0
  52. package/esm2022/spreadsheet/models/row.model.mjs +2 -0
  53. package/esm2022/spreadsheet/models/selection.model.mjs +2 -0
  54. package/esm2022/spreadsheet/models/sort.model.mjs +2 -0
  55. package/esm2022/spreadsheet/ngxsmk-cdk-spreadsheet.mjs +5 -0
  56. package/esm2022/spreadsheet/plugin/clipboard-plugin.mjs +171 -0
  57. package/esm2022/spreadsheet/plugin/filter-plugin.mjs +87 -0
  58. package/esm2022/spreadsheet/plugin/formula-plugin.mjs +456 -0
  59. package/esm2022/spreadsheet/plugin/index.mjs +10 -0
  60. package/esm2022/spreadsheet/plugin/plugin-host.mjs +188 -0
  61. package/esm2022/spreadsheet/plugin/plugin.types.mjs +2 -0
  62. package/esm2022/spreadsheet/plugin/selection-plugin.mjs +114 -0
  63. package/esm2022/spreadsheet/plugin/sort-plugin.mjs +63 -0
  64. package/esm2022/spreadsheet/plugin/undo-plugin.mjs +138 -0
  65. package/esm2022/spreadsheet/plugin/validation-plugin.mjs +71 -0
  66. package/esm2022/spreadsheet/spreadsheet-engine.mjs +547 -0
  67. package/esm2022/spreadsheet/utils/cell-utils.mjs +129 -0
  68. package/esm2022/spreadsheet/utils/index.mjs +2 -0
  69. package/fesm2022/ngxsmk-cdk-cva-base.mjs +52 -0
  70. package/fesm2022/ngxsmk-cdk-cva-base.mjs.map +1 -0
  71. package/fesm2022/ngxsmk-cdk-input-group.mjs +226 -0
  72. package/fesm2022/ngxsmk-cdk-input-group.mjs.map +1 -0
  73. package/fesm2022/ngxsmk-cdk-listbox-keyboard.mjs +152 -0
  74. package/fesm2022/ngxsmk-cdk-listbox-keyboard.mjs.map +1 -0
  75. package/fesm2022/ngxsmk-cdk-roving-focus.mjs +183 -0
  76. package/fesm2022/ngxsmk-cdk-roving-focus.mjs.map +1 -0
  77. package/fesm2022/ngxsmk-cdk-scheduler.mjs +1035 -0
  78. package/fesm2022/ngxsmk-cdk-scheduler.mjs.map +1 -0
  79. package/fesm2022/ngxsmk-cdk-scroll-lock.mjs +21 -4
  80. package/fesm2022/ngxsmk-cdk-scroll-lock.mjs.map +1 -1
  81. package/fesm2022/ngxsmk-cdk-skip-link.mjs +40 -0
  82. package/fesm2022/ngxsmk-cdk-skip-link.mjs.map +1 -0
  83. package/fesm2022/ngxsmk-cdk-spreadsheet.mjs +1969 -0
  84. package/fesm2022/ngxsmk-cdk-spreadsheet.mjs.map +1 -0
  85. package/fesm2022/ngxsmk-cdk.mjs +5 -0
  86. package/fesm2022/ngxsmk-cdk.mjs.map +1 -1
  87. package/input-group/index.d.ts +3 -0
  88. package/input-group/index.d.ts.map +1 -0
  89. package/input-group/input-group-engine.d.ts +88 -0
  90. package/input-group/input-group-engine.d.ts.map +1 -0
  91. package/input-group/models/index.d.ts +2 -0
  92. package/input-group/models/index.d.ts.map +1 -0
  93. package/input-group/models/input-group.model.d.ts +114 -0
  94. package/input-group/models/input-group.model.d.ts.map +1 -0
  95. package/input-group/ngxsmk-cdk-input-group.d.ts.map +1 -0
  96. package/listbox-keyboard/index.d.ts +2 -0
  97. package/listbox-keyboard/index.d.ts.map +1 -0
  98. package/listbox-keyboard/listbox-keyboard.d.ts +60 -0
  99. package/listbox-keyboard/listbox-keyboard.d.ts.map +1 -0
  100. package/listbox-keyboard/ngxsmk-cdk-listbox-keyboard.d.ts.map +1 -0
  101. package/package.json +59 -17
  102. package/public-api.d.ts +8 -0
  103. package/public-api.d.ts.map +1 -1
  104. package/roving-focus/index.d.ts +2 -0
  105. package/roving-focus/index.d.ts.map +1 -0
  106. package/roving-focus/ngxsmk-cdk-roving-focus.d.ts.map +1 -0
  107. package/roving-focus/roving-focus.d.ts +61 -0
  108. package/roving-focus/roving-focus.d.ts.map +1 -0
  109. package/scheduler/index.d.ts +5 -0
  110. package/scheduler/index.d.ts.map +1 -0
  111. package/scheduler/models/date-range.d.ts +6 -0
  112. package/scheduler/models/date-range.d.ts.map +1 -0
  113. package/scheduler/models/event.model.d.ts +50 -0
  114. package/scheduler/models/event.model.d.ts.map +1 -0
  115. package/scheduler/models/index.d.ts +6 -0
  116. package/scheduler/models/index.d.ts.map +1 -0
  117. package/scheduler/models/resource.model.d.ts +16 -0
  118. package/scheduler/models/resource.model.d.ts.map +1 -0
  119. package/scheduler/models/selection.model.d.ts +12 -0
  120. package/scheduler/models/selection.model.d.ts.map +1 -0
  121. package/scheduler/models/view.model.d.ts +21 -0
  122. package/scheduler/models/view.model.d.ts.map +1 -0
  123. package/scheduler/ngxsmk-cdk-scheduler.d.ts.map +1 -0
  124. package/scheduler/plugin/drag-plugin.d.ts +28 -0
  125. package/scheduler/plugin/drag-plugin.d.ts.map +1 -0
  126. package/scheduler/plugin/index.d.ts +8 -0
  127. package/scheduler/plugin/index.d.ts.map +1 -0
  128. package/scheduler/plugin/keyboard-plugin.d.ts +24 -0
  129. package/scheduler/plugin/keyboard-plugin.d.ts.map +1 -0
  130. package/scheduler/plugin/plugin-host.d.ts +16 -0
  131. package/scheduler/plugin/plugin-host.d.ts.map +1 -0
  132. package/scheduler/plugin/plugin.types.d.ts +9 -0
  133. package/scheduler/plugin/plugin.types.d.ts.map +1 -0
  134. package/scheduler/plugin/resize-plugin.d.ts +26 -0
  135. package/scheduler/plugin/resize-plugin.d.ts.map +1 -0
  136. package/scheduler/plugin/selection-plugin.d.ts +40 -0
  137. package/scheduler/plugin/selection-plugin.d.ts.map +1 -0
  138. package/scheduler/plugin/tooltip-plugin.d.ts +41 -0
  139. package/scheduler/plugin/tooltip-plugin.d.ts.map +1 -0
  140. package/scheduler/scheduler-engine.d.ts +95 -0
  141. package/scheduler/scheduler-engine.d.ts.map +1 -0
  142. package/scheduler/utils/date-utils.d.ts +26 -0
  143. package/scheduler/utils/date-utils.d.ts.map +1 -0
  144. package/scheduler/utils/index.d.ts +3 -0
  145. package/scheduler/utils/index.d.ts.map +1 -0
  146. package/scheduler/utils/snap.d.ts +7 -0
  147. package/scheduler/utils/snap.d.ts.map +1 -0
  148. package/scroll-lock/scroll-lock.d.ts +2 -0
  149. package/scroll-lock/scroll-lock.d.ts.map +1 -1
  150. package/skip-link/index.d.ts +2 -0
  151. package/skip-link/index.d.ts.map +1 -0
  152. package/skip-link/ngxsmk-cdk-skip-link.d.ts.map +1 -0
  153. package/skip-link/skip-link.d.ts +18 -0
  154. package/skip-link/skip-link.d.ts.map +1 -0
  155. package/spreadsheet/index.d.ts +5 -0
  156. package/spreadsheet/index.d.ts.map +1 -0
  157. package/spreadsheet/models/cell.model.d.ts +44 -0
  158. package/spreadsheet/models/cell.model.d.ts.map +1 -0
  159. package/spreadsheet/models/column.model.d.ts +77 -0
  160. package/spreadsheet/models/column.model.d.ts.map +1 -0
  161. package/spreadsheet/models/filter.model.d.ts +15 -0
  162. package/spreadsheet/models/filter.model.d.ts.map +1 -0
  163. package/spreadsheet/models/formula.model.d.ts +65 -0
  164. package/spreadsheet/models/formula.model.d.ts.map +1 -0
  165. package/spreadsheet/models/index.d.ts +8 -0
  166. package/spreadsheet/models/index.d.ts.map +1 -0
  167. package/spreadsheet/models/row.model.d.ts +33 -0
  168. package/spreadsheet/models/row.model.d.ts.map +1 -0
  169. package/spreadsheet/models/selection.model.d.ts +18 -0
  170. package/spreadsheet/models/selection.model.d.ts.map +1 -0
  171. package/spreadsheet/models/sort.model.d.ts +14 -0
  172. package/spreadsheet/models/sort.model.d.ts.map +1 -0
  173. package/spreadsheet/ngxsmk-cdk-spreadsheet.d.ts.map +1 -0
  174. package/spreadsheet/plugin/clipboard-plugin.d.ts +24 -0
  175. package/spreadsheet/plugin/clipboard-plugin.d.ts.map +1 -0
  176. package/spreadsheet/plugin/filter-plugin.d.ts +21 -0
  177. package/spreadsheet/plugin/filter-plugin.d.ts.map +1 -0
  178. package/spreadsheet/plugin/formula-plugin.d.ts +39 -0
  179. package/spreadsheet/plugin/formula-plugin.d.ts.map +1 -0
  180. package/spreadsheet/plugin/index.d.ts +10 -0
  181. package/spreadsheet/plugin/index.d.ts.map +1 -0
  182. package/spreadsheet/plugin/plugin-host.d.ts +66 -0
  183. package/spreadsheet/plugin/plugin-host.d.ts.map +1 -0
  184. package/spreadsheet/plugin/plugin.types.d.ts +58 -0
  185. package/spreadsheet/plugin/plugin.types.d.ts.map +1 -0
  186. package/spreadsheet/plugin/selection-plugin.d.ts +28 -0
  187. package/spreadsheet/plugin/selection-plugin.d.ts.map +1 -0
  188. package/spreadsheet/plugin/sort-plugin.d.ts +18 -0
  189. package/spreadsheet/plugin/sort-plugin.d.ts.map +1 -0
  190. package/spreadsheet/plugin/undo-plugin.d.ts +40 -0
  191. package/spreadsheet/plugin/undo-plugin.d.ts.map +1 -0
  192. package/spreadsheet/plugin/validation-plugin.d.ts +29 -0
  193. package/spreadsheet/plugin/validation-plugin.d.ts.map +1 -0
  194. package/spreadsheet/spreadsheet-engine.d.ts +194 -0
  195. package/spreadsheet/spreadsheet-engine.d.ts.map +1 -0
  196. package/spreadsheet/utils/cell-utils.d.ts +26 -0
  197. package/spreadsheet/utils/cell-utils.d.ts.map +1 -0
  198. package/spreadsheet/utils/index.d.ts +2 -0
  199. package/spreadsheet/utils/index.d.ts.map +1 -0
@@ -0,0 +1,1969 @@
1
+ import { signal, computed } from '@angular/core';
2
+
3
+ /**
4
+ * PluginHost manages the lifecycle of all registered plugins.
5
+ * Plugins are executed in priority order (lower = first).
6
+ */
7
+ class PluginHost {
8
+ constructor() {
9
+ this._plugins = [];
10
+ this._engine = null;
11
+ }
12
+ /** Register one or more plugins. */
13
+ register(...plugins) {
14
+ this._plugins.push(...plugins);
15
+ this._plugins.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
16
+ }
17
+ /** Unregister plugins by name. */
18
+ unregister(...names) {
19
+ const nameSet = new Set(names);
20
+ const toRemove = this._plugins.filter((p) => nameSet.has(p.name));
21
+ toRemove.forEach((p) => p.onDestroy?.());
22
+ this._plugins = this._plugins.filter((p) => !nameSet.has(p.name));
23
+ }
24
+ /** Initialize all plugins with the engine. Called once during engine construction. */
25
+ init(engine) {
26
+ this._engine = engine;
27
+ for (const plugin of this._plugins) {
28
+ plugin.onInit?.(engine);
29
+ }
30
+ }
31
+ /** Destroy all plugins. */
32
+ destroy() {
33
+ for (const plugin of this._plugins) {
34
+ plugin.onDestroy?.();
35
+ }
36
+ this._plugins = [];
37
+ this._engine = null;
38
+ }
39
+ /** Run onBeforeRender on all plugins. Returns false if any plugin cancels. */
40
+ beforeRender() {
41
+ for (const plugin of this._plugins) {
42
+ if (plugin.onBeforeRender?.() === false)
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+ /** Run onAfterRender on all plugins. */
48
+ afterRender() {
49
+ for (const plugin of this._plugins) {
50
+ plugin.onAfterRender?.();
51
+ }
52
+ }
53
+ /** Run onBeforeEdit on all plugins. Returns false if any cancels. */
54
+ beforeEdit(rowIndex, colId) {
55
+ for (const plugin of this._plugins) {
56
+ if (plugin.onBeforeEdit?.(rowIndex, colId) === false)
57
+ return false;
58
+ }
59
+ return true;
60
+ }
61
+ /** Run onAfterEdit on all plugins. */
62
+ afterEdit(rowIndex, colId, oldValue, newValue) {
63
+ for (const plugin of this._plugins) {
64
+ plugin.onAfterEdit?.(rowIndex, colId, oldValue, newValue);
65
+ }
66
+ }
67
+ /** Run onBeforeSort on all plugins. Returns false if any cancels. */
68
+ beforeSort(colId, direction) {
69
+ for (const plugin of this._plugins) {
70
+ if (plugin.onBeforeSort?.(colId, direction) === false)
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+ /** Run onAfterSort on all plugins. */
76
+ afterSort() {
77
+ for (const plugin of this._plugins) {
78
+ plugin.onAfterSort?.();
79
+ }
80
+ }
81
+ /** Run onBeforeFilter on all plugins. Returns false if any cancels. */
82
+ beforeFilter() {
83
+ for (const plugin of this._plugins) {
84
+ if (plugin.onBeforeFilter?.() === false)
85
+ return false;
86
+ }
87
+ return true;
88
+ }
89
+ /** Run onAfterFilter on all plugins. */
90
+ afterFilter() {
91
+ for (const plugin of this._plugins) {
92
+ plugin.onAfterFilter?.();
93
+ }
94
+ }
95
+ /** Run onBeforeInsertRows on all plugins. Returns false if any cancels. */
96
+ beforeInsertRows(count, atIndex) {
97
+ for (const plugin of this._plugins) {
98
+ if (plugin.onBeforeInsertRows?.(count, atIndex) === false)
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ /** Run onAfterInsertRows on all plugins. */
104
+ afterInsertRows(indices) {
105
+ for (const plugin of this._plugins) {
106
+ plugin.onAfterInsertRows?.(indices);
107
+ }
108
+ }
109
+ /** Run onBeforeDeleteRows on all plugins. Returns false if any cancels. */
110
+ beforeDeleteRows(indices) {
111
+ for (const plugin of this._plugins) {
112
+ if (plugin.onBeforeDeleteRows?.(indices) === false)
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ /** Run onAfterDeleteRows on all plugins. */
118
+ afterDeleteRows(indices) {
119
+ for (const plugin of this._plugins) {
120
+ plugin.onAfterDeleteRows?.(indices);
121
+ }
122
+ }
123
+ /** Run onBeforeInsertColumns on all plugins. Returns false if any cancels. */
124
+ beforeInsertColumns(count, atIndex) {
125
+ for (const plugin of this._plugins) {
126
+ if (plugin.onBeforeInsertColumns?.(count, atIndex) === false)
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+ /** Run onAfterInsertColumns on all plugins. */
132
+ afterInsertColumns(indices) {
133
+ for (const plugin of this._plugins) {
134
+ plugin.onAfterInsertColumns?.(indices);
135
+ }
136
+ }
137
+ /** Run onBeforeDeleteColumns on all plugins. Returns false if any cancels. */
138
+ beforeDeleteColumns(indices) {
139
+ for (const plugin of this._plugins) {
140
+ if (plugin.onBeforeDeleteColumns?.(indices) === false)
141
+ return false;
142
+ }
143
+ return true;
144
+ }
145
+ /** Run onAfterDeleteColumns on all plugins. */
146
+ afterDeleteColumns(indices) {
147
+ for (const plugin of this._plugins) {
148
+ plugin.onAfterDeleteColumns?.(indices);
149
+ }
150
+ }
151
+ /** Run onBeforePaste on all plugins. Returns false if any cancels. */
152
+ beforePaste(data, startCell) {
153
+ for (const plugin of this._plugins) {
154
+ if (plugin.onBeforePaste?.(data, startCell) === false)
155
+ return false;
156
+ }
157
+ return true;
158
+ }
159
+ /** Run onAfterPaste on all plugins. */
160
+ afterPaste() {
161
+ for (const plugin of this._plugins) {
162
+ plugin.onAfterPaste?.();
163
+ }
164
+ }
165
+ /** Run onBeforeCopy on all plugins. Returns false if any cancels. */
166
+ beforeCopy() {
167
+ for (const plugin of this._plugins) {
168
+ if (plugin.onBeforeCopy?.() === false)
169
+ return false;
170
+ }
171
+ return true;
172
+ }
173
+ /** Run onAfterCopy on all plugins. */
174
+ afterCopy() {
175
+ for (const plugin of this._plugins) {
176
+ plugin.onAfterCopy?.();
177
+ }
178
+ }
179
+ /** Fire a custom event to all plugins. */
180
+ fireEvent(eventType, payload) {
181
+ for (const plugin of this._plugins) {
182
+ plugin.onEvent?.(eventType, payload);
183
+ }
184
+ }
185
+ /** Get all registered plugins. */
186
+ get plugins() {
187
+ return this._plugins;
188
+ }
189
+ }
190
+
191
+ /** Convert a column index to an Excel-style column letter (0=A, 25=Z, 26=AA). */
192
+ function colIndexToLetter(index) {
193
+ let letter = '';
194
+ let i = index;
195
+ while (i >= 0) {
196
+ letter = String.fromCharCode(65 + (i % 26)) + letter;
197
+ i = Math.floor(i / 26) - 1;
198
+ }
199
+ return letter;
200
+ }
201
+ /** Convert an Excel-style column letter to a 0-based index. */
202
+ function colLetterToIndex(letter) {
203
+ let index = 0;
204
+ for (let i = 0; i < letter.length; i++) {
205
+ index = index * 26 + (letter.charCodeAt(i) - 64);
206
+ }
207
+ return index - 1;
208
+ }
209
+ /** Build a cell reference string like "A1", "B5". */
210
+ function cellRef(row, col) {
211
+ return `${colIndexToLetter(col)}${row + 1}`;
212
+ }
213
+ /** Parse a cell reference string like "A1" into a CellAddress. */
214
+ function parseCellRef(ref) {
215
+ const match = ref.match(/^([A-Z]+)(\d+)$/i);
216
+ if (!match)
217
+ return null;
218
+ return { row: parseInt(match[2], 10) - 1, col: colLetterToIndex(match[1].toUpperCase()) };
219
+ }
220
+ /** Normalize a range so start <= end in both dimensions. */
221
+ function normalizeRange(range) {
222
+ return {
223
+ start: {
224
+ row: Math.min(range.start.row, range.end.row),
225
+ col: Math.min(range.start.col, range.end.col),
226
+ },
227
+ end: {
228
+ row: Math.max(range.start.row, range.end.row),
229
+ col: Math.max(range.start.col, range.end.col),
230
+ },
231
+ };
232
+ }
233
+ /** Check if a cell address is within a range. */
234
+ function isCellInRange(address, range) {
235
+ const nr = normalizeRange(range);
236
+ return (address.row >= nr.start.row &&
237
+ address.row <= nr.end.row &&
238
+ address.col >= nr.start.col &&
239
+ address.col <= nr.end.col);
240
+ }
241
+ /** Get all cell addresses in a range. */
242
+ function cellsInRange(range) {
243
+ const nr = normalizeRange(range);
244
+ const cells = [];
245
+ for (let r = nr.start.row; r <= nr.end.row; r++) {
246
+ for (let c = nr.start.col; c <= nr.end.col; c++) {
247
+ cells.push({ row: r, col: c });
248
+ }
249
+ }
250
+ return cells;
251
+ }
252
+ /** Format a cell value for display. */
253
+ function formatCellValue(value, format) {
254
+ if (value == null || value === '')
255
+ return '';
256
+ if (typeof value === 'boolean')
257
+ return value ? 'TRUE' : 'FALSE';
258
+ if (value instanceof Date) {
259
+ return value.toLocaleDateString();
260
+ }
261
+ if (typeof value === 'number') {
262
+ if (format) {
263
+ return applyNumberFormat(value, format);
264
+ }
265
+ return String(value);
266
+ }
267
+ return String(value);
268
+ }
269
+ /** Apply a basic number format string. */
270
+ function applyNumberFormat(value, format) {
271
+ const isNeg = value < 0;
272
+ const abs = Math.abs(value);
273
+ // Currency format: $#,##0.00
274
+ if (format.includes('$')) {
275
+ const decimals = format.includes('.00') ? 2 : 0;
276
+ return `${isNeg ? '-' : ''}$${abs.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
277
+ }
278
+ // Percentage format
279
+ if (format.includes('%')) {
280
+ return `${(abs * 100).toFixed(format.includes('.00') ? 2 : 0)}%`;
281
+ }
282
+ // Plain number with commas: #,##0
283
+ if (format.includes(',')) {
284
+ return `${isNeg ? '-' : ''}${abs.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
285
+ }
286
+ return String(value);
287
+ }
288
+ /** Create a new RowDef with empty cells for each column. */
289
+ function createEmptyRow(id, columns) {
290
+ const cells = {};
291
+ for (const col of columns) {
292
+ cells[col.id] = {
293
+ value: (col.defaultValue ?? null),
294
+ meta: { type: col.cellType ?? 'text' },
295
+ };
296
+ }
297
+ return { id, cells, selectable: true, editable: true };
298
+ }
299
+ /** Generate a unique row ID. */
300
+ function generateRowId() {
301
+ return `row_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
302
+ }
303
+ /** Deep clone a row's cell data (for immutable updates). */
304
+ function cloneRow(row) {
305
+ return {
306
+ ...row,
307
+ cells: Object.fromEntries(Object.entries(row.cells).map(([k, v]) => [
308
+ k,
309
+ { ...v, meta: v.meta ? { ...v.meta } : undefined },
310
+ ])),
311
+ };
312
+ }
313
+ /** Calculate the total width of pinned columns. */
314
+ function pinnedWidth(columns, states, side) {
315
+ return columns
316
+ .filter((c) => c.pinned === side)
317
+ .reduce((sum, c) => sum + (states.get(c.id) ?? c.width ?? 150), 0);
318
+ }
319
+
320
+ const DENSITY_ROW_HEIGHT = {
321
+ compact: 28,
322
+ comfortable: 36,
323
+ dense: 24,
324
+ };
325
+ const DENSITY_HEADER_HEIGHT = {
326
+ compact: 32,
327
+ comfortable: 40,
328
+ dense: 28,
329
+ };
330
+ const DENSITY_CELL_PADDING = {
331
+ compact: '2px 6px',
332
+ comfortable: '6px 10px',
333
+ dense: '2px 4px',
334
+ };
335
+ /**
336
+ * SpreadsheetEngine — headless, signal-based state machine for spreadsheet data.
337
+ * Zero DOM, zero Angular dependencies beyond signals.
338
+ *
339
+ * Supports: columns, rows, sorting, filtering, selection, editing,
340
+ * undo/redo, clipboard, formulas, tree data, virtual scrolling state.
341
+ */
342
+ class SpreadsheetEngine {
343
+ constructor(config = {}) {
344
+ this._config = config;
345
+ this.pluginHost = new PluginHost();
346
+ // Density
347
+ this.density = signal(config.density ?? 'comfortable');
348
+ this.editable = signal(config.editable ?? true);
349
+ this.rtl = signal(config.rtl ?? false);
350
+ this.frozenRows = signal(config.frozenRows ?? 0);
351
+ this.frozenColumns = signal(config.frozenColumns ?? 0);
352
+ // Row/column height computed from density
353
+ this.rowHeight = computed(() => DENSITY_ROW_HEIGHT[this.density()]);
354
+ this.headerHeight = computed(() => DENSITY_HEADER_HEIGHT[this.density()]);
355
+ this.cellPadding = computed(() => DENSITY_CELL_PADDING[this.density()]);
356
+ // Column definitions
357
+ this.columnDefs = signal(config.columns ?? []);
358
+ // Column states (width, visibility, pin, sort)
359
+ const initialStates = new Map();
360
+ (config.columns ?? []).forEach((col, idx) => {
361
+ initialStates.set(col.id, {
362
+ id: col.id,
363
+ width: col.width ?? 150,
364
+ visible: col.visible !== false,
365
+ pinned: col.pinned ?? false,
366
+ sort: col.sort ?? null,
367
+ sortIndex: undefined,
368
+ order: idx,
369
+ });
370
+ });
371
+ this.columnStates = signal(initialStates);
372
+ // Row data
373
+ this.rowData = signal(config.rows ?? []);
374
+ // Selection
375
+ this.selection = signal({
376
+ activeCell: null,
377
+ range: null,
378
+ selectedRows: new Set(),
379
+ selectedCols: new Set(),
380
+ mode: config.selectionMode ?? 'cell',
381
+ dragging: false,
382
+ });
383
+ // Sort
384
+ this.sortState = signal({
385
+ criteria: (config.columns ?? [])
386
+ .filter((c) => c.sort)
387
+ .map((c, i) => ({ colId: c.id, direction: c.sort, index: i })),
388
+ multiSort: config.multiSort ?? false,
389
+ });
390
+ // Filter
391
+ this.filterState = signal({ criteria: [], mode: 'and' });
392
+ // Editing
393
+ this.editingCell = signal(null);
394
+ this.editingValue = signal(null);
395
+ // Scroll
396
+ this.scrollTop = signal(0);
397
+ this.scrollLeft = signal(0);
398
+ this.viewportHeight = signal(600);
399
+ this.viewportWidth = signal(800);
400
+ // Plugins
401
+ this.plugins = signal(config.plugins ?? []);
402
+ // Lifecycle hooks
403
+ this.lifecycle = {
404
+ beforeEdit: signal(() => true),
405
+ afterEdit: signal(() => {
406
+ /* noop */
407
+ }),
408
+ beforeSort: signal(() => true),
409
+ afterSort: signal(() => {
410
+ /* noop */
411
+ }),
412
+ beforeFilter: signal(() => true),
413
+ afterFilter: signal(() => {
414
+ /* noop */
415
+ }),
416
+ beforeInsertRows: signal(() => true),
417
+ afterInsertRows: signal(() => {
418
+ /* noop */
419
+ }),
420
+ beforeDeleteRows: signal(() => true),
421
+ afterDeleteRows: signal(() => {
422
+ /* noop */
423
+ }),
424
+ beforeInsertColumns: signal(() => true),
425
+ afterInsertColumns: signal(() => {
426
+ /* noop */
427
+ }),
428
+ beforeDeleteColumns: signal(() => true),
429
+ afterDeleteColumns: signal(() => {
430
+ /* noop */
431
+ }),
432
+ beforeCopy: signal(() => true),
433
+ afterCopy: signal(() => {
434
+ /* noop */
435
+ }),
436
+ beforePaste: signal(() => true),
437
+ afterPaste: signal(() => {
438
+ /* noop */
439
+ }),
440
+ };
441
+ // ── Computed ──
442
+ this.visibleColumns = computed(() => {
443
+ const states = this.columnStates();
444
+ return this.columnDefs().filter((col) => {
445
+ const st = states.get(col.id);
446
+ return st?.visible ?? true;
447
+ });
448
+ });
449
+ this.pinnedLeftColumns = computed(() => this.visibleColumns().filter((c) => c.pinned === 'left'));
450
+ this.pinnedRightColumns = computed(() => this.visibleColumns().filter((c) => c.pinned === 'right'));
451
+ this.unpinnedColumns = computed(() => this.visibleColumns().filter((c) => !c.pinned));
452
+ this.columnWidths = computed(() => {
453
+ const states = this.columnStates();
454
+ const widths = new Map();
455
+ for (const col of this.columnDefs()) {
456
+ widths.set(col.id, states.get(col.id)?.width ?? col.width ?? 150);
457
+ }
458
+ return widths;
459
+ });
460
+ this.totalColumns = computed(() => this.visibleColumns().length);
461
+ this.visibleRows = computed(() => {
462
+ return this.rowData();
463
+ });
464
+ this.displayRows = computed(() => {
465
+ return this.visibleRows();
466
+ });
467
+ this.totalRows = computed(() => this.displayRows().length);
468
+ this.rowHeight = computed(() => DENSITY_ROW_HEIGHT[this.density()]);
469
+ this.totalHeight = computed(() => this.totalRows() * this.rowHeight());
470
+ this.totalWidth = computed(() => {
471
+ const widths = this.columnWidths();
472
+ let total = 0;
473
+ for (const col of this.visibleColumns()) {
474
+ total += widths.get(col.id) ?? 150;
475
+ }
476
+ return total;
477
+ });
478
+ // Virtual scroll range — which rows are visible
479
+ this.virtualRowRange = computed(() => {
480
+ const rh = this.rowHeight();
481
+ const vh = this.viewportHeight();
482
+ const st = this.scrollTop();
483
+ const total = this.totalRows();
484
+ const buffer = config.bufferRows ?? 5;
485
+ const start = Math.max(0, Math.floor(st / rh) - buffer);
486
+ const visibleCount = Math.ceil(vh / rh);
487
+ const end = Math.min(total, start + visibleCount + buffer * 2);
488
+ return {
489
+ start,
490
+ end,
491
+ offsetY: start * rh,
492
+ totalHeight: total * rh,
493
+ };
494
+ });
495
+ // Virtual scroll range — which columns are visible
496
+ this.virtualColRange = computed(() => {
497
+ const widths = this.columnWidths();
498
+ const vw = this.viewportWidth();
499
+ const sl = this.scrollLeft();
500
+ const cols = this.unpinnedColumns();
501
+ const buffer = 3;
502
+ let start = 0;
503
+ let end = cols.length;
504
+ // Find start column
505
+ let accWidth = 0;
506
+ for (let i = 0; i < cols.length; i++) {
507
+ const w = widths.get(cols[i].id) ?? 150;
508
+ if (accWidth + w > sl) {
509
+ start = Math.max(0, i - buffer);
510
+ break;
511
+ }
512
+ accWidth += w;
513
+ }
514
+ // Find end column
515
+ let accWidth2 = 0;
516
+ for (let i = start; i < cols.length; i++) {
517
+ accWidth2 += widths.get(cols[i].id) ?? 150;
518
+ if (accWidth2 > vw + sl) {
519
+ end = Math.min(cols.length, i + buffer + 1);
520
+ break;
521
+ }
522
+ }
523
+ // Recalculate offsetX
524
+ let finalOffsetX = 0;
525
+ for (let i = 0; i < start; i++) {
526
+ finalOffsetX += widths.get(cols[i].id) ?? 150;
527
+ }
528
+ return {
529
+ start,
530
+ end,
531
+ offsetX: finalOffsetX,
532
+ totalWidth: cols.reduce((sum, c) => sum + (widths.get(c.id) ?? 150), 0),
533
+ };
534
+ });
535
+ // Initialize plugins
536
+ this.pluginHost.init(this);
537
+ }
538
+ // ── Column Operations ──
539
+ /** Get column state by ID. */
540
+ getColumnState(colId) {
541
+ return this.columnStates().get(colId);
542
+ }
543
+ /** Set column width. */
544
+ setColumnWidth(colId, width) {
545
+ const states = new Map(this.columnStates());
546
+ const st = states.get(colId);
547
+ if (st) {
548
+ const col = this.columnDefs().find((c) => c.id === colId);
549
+ const min = col?.minWidth ?? 40;
550
+ const max = col?.maxWidth ?? 600;
551
+ states.set(colId, { ...st, width: Math.max(min, Math.min(max, width)) });
552
+ this.columnStates.set(states);
553
+ }
554
+ }
555
+ /** Toggle column visibility. */
556
+ toggleColumnVisibility(colId) {
557
+ const states = new Map(this.columnStates());
558
+ const st = states.get(colId);
559
+ if (st) {
560
+ states.set(colId, { ...st, visible: !st.visible });
561
+ this.columnStates.set(states);
562
+ }
563
+ }
564
+ /** Set column sort direction. */
565
+ setColumnSort(colId, direction) {
566
+ const states = new Map(this.columnStates());
567
+ const st = states.get(colId);
568
+ if (st) {
569
+ states.set(colId, { ...st, sort: direction });
570
+ this.columnStates.set(states);
571
+ }
572
+ // Update sort state
573
+ const current = this.sortState();
574
+ let criteria;
575
+ if (current.multiSort) {
576
+ const existing = current.criteria.filter((c) => c.colId !== colId);
577
+ if (direction) {
578
+ criteria = [...existing, { colId, direction, index: existing.length }];
579
+ }
580
+ else {
581
+ criteria = existing;
582
+ }
583
+ }
584
+ else {
585
+ // Single sort: clear other sorts
586
+ for (const [id, s] of states) {
587
+ if (id !== colId && s.sort) {
588
+ states.set(id, { ...s, sort: null });
589
+ }
590
+ }
591
+ this.columnStates.set(states);
592
+ criteria = direction ? [{ colId, direction, index: 0 }] : [];
593
+ }
594
+ this.sortState.set({ ...current, criteria });
595
+ }
596
+ /** Reorder columns. */
597
+ moveColumn(fromIndex, toIndex) {
598
+ const cols = [...this.columnDefs()];
599
+ const [moved] = cols.splice(fromIndex, 1);
600
+ if (moved) {
601
+ cols.splice(toIndex, 0, moved);
602
+ this.columnDefs.set(cols);
603
+ }
604
+ }
605
+ // ── Row Operations ──
606
+ /** Insert rows at the given index. */
607
+ insertRows(count, atIndex) {
608
+ const index = atIndex ?? this.rowData().length;
609
+ const rows = [...this.rowData()];
610
+ const newIndices = [];
611
+ for (let i = 0; i < count; i++) {
612
+ const newRow = createEmptyRow(generateRowId(), this.columnDefs());
613
+ rows.splice(index + i, 0, newRow);
614
+ newIndices.push(index + i);
615
+ }
616
+ this.rowData.set(rows);
617
+ return newIndices;
618
+ }
619
+ /** Delete rows by index. */
620
+ deleteRows(indices) {
621
+ const sorted = [...indices].sort((a, b) => b - a);
622
+ const rows = [...this.rowData()];
623
+ const deleted = [];
624
+ for (const idx of sorted) {
625
+ if (idx >= 0 && idx < rows.length) {
626
+ deleted.push(rows[idx]);
627
+ rows.splice(idx, 1);
628
+ }
629
+ }
630
+ this.rowData.set(rows);
631
+ return deleted;
632
+ }
633
+ // ── Cell Operations ──
634
+ /** Get a cell value. */
635
+ getCellValue(rowIndex, colId) {
636
+ return this.rowData()[rowIndex]?.cells[colId]?.value ?? null;
637
+ }
638
+ /** Set a cell value. */
639
+ setCellValue(rowIndex, colId, value) {
640
+ const rows = [...this.rowData()];
641
+ const row = rows[rowIndex];
642
+ if (!row)
643
+ return;
644
+ rows[rowIndex] = {
645
+ ...row,
646
+ cells: {
647
+ ...row.cells,
648
+ [colId]: { value, meta: row.cells[colId]?.meta },
649
+ },
650
+ };
651
+ this.rowData.set(rows);
652
+ }
653
+ /** Get cell metadata. */
654
+ getCellMeta(rowIndex, colId) {
655
+ return this.rowData()[rowIndex]?.cells[colId]?.meta;
656
+ }
657
+ /** Get a column definition by ID. */
658
+ getColumnDef(colId) {
659
+ return this.columnDefs().find((c) => c.id === colId);
660
+ }
661
+ /** Get the 0-based column index by ID. */
662
+ getColIndex(colId) {
663
+ return this.visibleColumns().findIndex((c) => c.id === colId);
664
+ }
665
+ // ── Editing ──
666
+ /** Begin editing a cell. */
667
+ startEdit(rowIndex, colId) {
668
+ if (!this.editable())
669
+ return false;
670
+ if (!this.pluginHost.beforeEdit(rowIndex, colId))
671
+ return false;
672
+ const value = this.getCellValue(rowIndex, colId);
673
+ this.editingCell.set({ row: rowIndex, col: this.getColIndex(colId) });
674
+ this.editingValue.set(value);
675
+ return true;
676
+ }
677
+ /** Commit the current edit. */
678
+ commitEdit() {
679
+ const cell = this.editingCell();
680
+ if (!cell)
681
+ return;
682
+ const col = this.visibleColumns()[cell.col];
683
+ if (!col)
684
+ return;
685
+ const oldValue = this.getCellValue(cell.row, col.id);
686
+ const newValue = this.editingValue();
687
+ if (oldValue !== newValue) {
688
+ this.setCellValue(cell.row, col.id, newValue);
689
+ this.pluginHost.afterEdit(cell.row, col.id, oldValue, newValue);
690
+ }
691
+ this.editingCell.set(null);
692
+ }
693
+ /** Cancel the current edit. */
694
+ cancelEdit() {
695
+ this.editingCell.set(null);
696
+ this.editingValue.set(null);
697
+ }
698
+ // ── Sort ──
699
+ /** Sort by a column (toggles direction). */
700
+ sortBy(colId) {
701
+ const st = this.columnStates().get(colId);
702
+ if (!st)
703
+ return;
704
+ const currentSort = st.sort;
705
+ let newDirection;
706
+ if (currentSort === null) {
707
+ newDirection = 'asc';
708
+ }
709
+ else if (currentSort === 'asc') {
710
+ newDirection = 'desc';
711
+ }
712
+ else {
713
+ newDirection = null;
714
+ }
715
+ if (!this.pluginHost.beforeSort(colId, newDirection))
716
+ return;
717
+ this.setColumnSort(colId, newDirection);
718
+ this.pluginHost.afterSort();
719
+ }
720
+ /** Clear all sorting. */
721
+ clearSort() {
722
+ const states = new Map(this.columnStates());
723
+ for (const [id, st] of states) {
724
+ if (st.sort) {
725
+ states.set(id, { ...st, sort: null, sortIndex: undefined });
726
+ }
727
+ }
728
+ this.columnStates.set(states);
729
+ this.sortState.set({ criteria: [], multiSort: this.sortState().multiSort });
730
+ }
731
+ // ── Filter ──
732
+ /** Apply a filter. */
733
+ applyFilter(colId, operator, value, valueTo) {
734
+ const current = this.filterState();
735
+ const existing = current.criteria.filter((c) => c.colId !== colId);
736
+ const criteria = [...existing, { colId, operator, value, valueTo }];
737
+ this.filterState.set({ ...current, criteria });
738
+ }
739
+ /** Clear filter for a column. */
740
+ clearColumnFilter(colId) {
741
+ const current = this.filterState();
742
+ this.filterState.set({
743
+ ...current,
744
+ criteria: current.criteria.filter((c) => c.colId !== colId),
745
+ });
746
+ }
747
+ /** Clear all filters. */
748
+ clearFilters() {
749
+ this.filterState.set({ criteria: [], mode: 'and' });
750
+ }
751
+ // ── Navigation ──
752
+ /** Move the active cell by delta. */
753
+ navigate(deltaRow, deltaCol) {
754
+ const sel = this.selection();
755
+ const current = sel.activeCell ?? { row: 0, col: 0 };
756
+ const newRow = Math.max(0, Math.min(this.totalRows() - 1, current.row + deltaRow));
757
+ const newCol = Math.max(0, Math.min(this.totalColumns() - 1, current.col + deltaCol));
758
+ this.selection.set({
759
+ ...sel,
760
+ activeCell: { row: newRow, col: newCol },
761
+ range: { start: { row: newRow, col: newCol }, end: { row: newRow, col: newCol } },
762
+ });
763
+ }
764
+ /** Scroll to make a cell visible. */
765
+ scrollToCell(rowIndex, _colIndex) {
766
+ const rh = this.rowHeight();
767
+ const cellTop = rowIndex * rh;
768
+ const cellBottom = cellTop + rh;
769
+ const st = this.scrollTop();
770
+ const vh = this.viewportHeight();
771
+ if (cellTop < st) {
772
+ this.scrollTop.set(cellTop);
773
+ }
774
+ else if (cellBottom > st + vh) {
775
+ this.scrollTop.set(cellBottom - vh);
776
+ }
777
+ }
778
+ // ── Selection ──
779
+ /** Select a cell. */
780
+ selectCell(row, col) {
781
+ this.selection.set({
782
+ ...this.selection(),
783
+ activeCell: { row, col },
784
+ range: { start: { row, col }, end: { row, col } },
785
+ mode: 'cell',
786
+ });
787
+ }
788
+ /** Select a range. */
789
+ selectRange(start, end) {
790
+ this.selection.set({
791
+ ...this.selection(),
792
+ range: { start, end },
793
+ mode: 'range',
794
+ });
795
+ }
796
+ /** Select a row. */
797
+ selectRow(rowIndex) {
798
+ const rows = new Set(this.selection().selectedRows);
799
+ if (rows.has(rowIndex)) {
800
+ rows.delete(rowIndex);
801
+ }
802
+ else {
803
+ rows.add(rowIndex);
804
+ }
805
+ this.selection.set({ ...this.selection(), selectedRows: rows, mode: 'row' });
806
+ }
807
+ /** Select a column. */
808
+ selectColumn(colIndex) {
809
+ const cols = new Set(this.selection().selectedCols);
810
+ if (cols.has(colIndex)) {
811
+ cols.delete(colIndex);
812
+ }
813
+ else {
814
+ cols.add(colIndex);
815
+ }
816
+ this.selection.set({ ...this.selection(), selectedCols: cols, mode: 'column' });
817
+ }
818
+ /** Select all cells. */
819
+ selectAll() {
820
+ const allRows = new Set(Array.from({ length: this.totalRows() }, (_, i) => i));
821
+ const allCols = new Set(Array.from({ length: this.totalColumns() }, (_, i) => i));
822
+ this.selection.set({
823
+ activeCell: { row: 0, col: 0 },
824
+ range: {
825
+ start: { row: 0, col: 0 },
826
+ end: { row: this.totalRows() - 1, col: this.totalColumns() - 1 },
827
+ },
828
+ selectedRows: allRows,
829
+ selectedCols: allCols,
830
+ mode: 'range',
831
+ dragging: false,
832
+ });
833
+ }
834
+ /** Check if a cell is selected. */
835
+ isCellSelected(row, col) {
836
+ const sel = this.selection();
837
+ if (sel.range) {
838
+ const minR = Math.min(sel.range.start.row, sel.range.end.row);
839
+ const maxR = Math.max(sel.range.start.row, sel.range.end.row);
840
+ const minC = Math.min(sel.range.start.col, sel.range.end.col);
841
+ const maxC = Math.max(sel.range.start.col, sel.range.end.col);
842
+ if (row >= minR && row <= maxR && col >= minC && col <= maxC)
843
+ return true;
844
+ }
845
+ return sel.selectedRows.has(row) || sel.selectedCols.has(col);
846
+ }
847
+ /** Check if a cell is the active cell. */
848
+ isCellActive(row, col) {
849
+ const ac = this.selection().activeCell;
850
+ return ac?.row === row && ac?.col === col;
851
+ }
852
+ // ── Refresh ──
853
+ /** Force recalculation by re-setting data. */
854
+ refresh() {
855
+ this.rowData.update((r) => [...r]);
856
+ this.columnStates.update((s) => new Map(s));
857
+ }
858
+ // ── Destroy ──
859
+ destroy() {
860
+ this.pluginHost.destroy();
861
+ }
862
+ }
863
+
864
+ /**
865
+ * SortPlugin manages multi-column sorting.
866
+ * Sorts the engine's rows by the active sort criteria.
867
+ */
868
+ class SortPlugin {
869
+ constructor() {
870
+ this.name = 'sort';
871
+ this.priority = 10;
872
+ this._engine = null;
873
+ }
874
+ onInit(engine) {
875
+ this._engine = engine;
876
+ }
877
+ /** Apply all active sort criteria to the engine's row list. */
878
+ applySort(criteria) {
879
+ if (!this._engine || criteria.length === 0)
880
+ return;
881
+ const defs = this._engine.columnDefs();
882
+ const sorted = [...this._engine.rowData()];
883
+ sorted.sort((a, b) => {
884
+ for (const crit of criteria) {
885
+ const col = defs.find((d) => d.id === crit.colId);
886
+ const aVal = a.cells[crit.colId]?.value;
887
+ const bVal = b.cells[crit.colId]?.value;
888
+ let cmp;
889
+ if (col?.comparator) {
890
+ cmp = col.comparator(aVal, bVal, crit.direction);
891
+ }
892
+ else {
893
+ cmp = defaultCompare(aVal, bVal);
894
+ }
895
+ if (cmp !== 0) {
896
+ return crit.direction === 'desc' ? -cmp : cmp;
897
+ }
898
+ }
899
+ return 0;
900
+ });
901
+ this._engine.rowData.set(sorted);
902
+ }
903
+ onBeforeSort(_colId, _direction) {
904
+ return true;
905
+ }
906
+ onAfterSort() {
907
+ const criteria = this._engine?.sortState().criteria;
908
+ if (criteria && criteria.length > 0) {
909
+ this.applySort(criteria);
910
+ }
911
+ }
912
+ }
913
+ function defaultCompare(a, b) {
914
+ if (a == null && b == null)
915
+ return 0;
916
+ if (a == null)
917
+ return -1;
918
+ if (b == null)
919
+ return 1;
920
+ if (typeof a === 'number' && typeof b === 'number')
921
+ return a - b;
922
+ if (a instanceof Date && b instanceof Date)
923
+ return a.getTime() - b.getTime();
924
+ return String(a).localeCompare(String(b));
925
+ }
926
+
927
+ /**
928
+ * FilterPlugin applies column-level filters to the row set.
929
+ * Stores original rows and restores on clear.
930
+ */
931
+ class FilterPlugin {
932
+ constructor() {
933
+ this.name = 'filter';
934
+ this.priority = 20;
935
+ this._engine = null;
936
+ this._originalRows = [];
937
+ }
938
+ onInit(engine) {
939
+ this._engine = engine;
940
+ this._originalRows = engine.rowData();
941
+ }
942
+ /** Apply filter criteria. */
943
+ applyFilter(criteria, mode) {
944
+ if (!this._engine)
945
+ return;
946
+ if (criteria.length === 0) {
947
+ this._engine.rowData.set([...this._originalRows]);
948
+ return;
949
+ }
950
+ this._originalRows = this._engine.rowData();
951
+ const filtered = this._originalRows.filter((row) => {
952
+ const results = criteria.map((c) => matchesCriterion(row.cells[c.colId]?.value, c));
953
+ return mode === 'and' ? results.every(Boolean) : results.some(Boolean);
954
+ });
955
+ this._engine.rowData.set(filtered);
956
+ }
957
+ /** Clear all filters and restore original rows. */
958
+ clearFilter() {
959
+ if (!this._engine)
960
+ return;
961
+ this._engine.rowData.set([...this._originalRows]);
962
+ }
963
+ onBeforeFilter() {
964
+ return true;
965
+ }
966
+ onAfterFilter() {
967
+ // Sync original rows when data changes externally
968
+ if (this._engine) {
969
+ this._originalRows = this._engine.rowData();
970
+ }
971
+ }
972
+ }
973
+ function matchesCriterion(value, criterion) {
974
+ const v = value == null ? '' : String(value).toLowerCase();
975
+ const c = criterion.value == null ? '' : String(criterion.value).toLowerCase();
976
+ switch (criterion.operator) {
977
+ case 'equals':
978
+ return v === c;
979
+ case 'notEquals':
980
+ return v !== c;
981
+ case 'contains':
982
+ return v.includes(c);
983
+ case 'notContains':
984
+ return !v.includes(c);
985
+ case 'startsWith':
986
+ return v.startsWith(c);
987
+ case 'endsWith':
988
+ return v.endsWith(c);
989
+ case 'gt':
990
+ return Number(value) > Number(criterion.value);
991
+ case 'gte':
992
+ return Number(value) >= Number(criterion.value);
993
+ case 'lt':
994
+ return Number(value) < Number(criterion.value);
995
+ case 'lte':
996
+ return Number(value) <= Number(criterion.value);
997
+ case 'between': {
998
+ const num = Number(value);
999
+ return num >= Number(criterion.value) && num <= Number(criterion.valueTo);
1000
+ }
1001
+ case 'in': {
1002
+ const arr = Array.isArray(criterion.value) ? criterion.value : [];
1003
+ return arr.some((item) => String(item).toLowerCase() === v);
1004
+ }
1005
+ case 'empty':
1006
+ return v === '';
1007
+ case 'notEmpty':
1008
+ return v !== '';
1009
+ default:
1010
+ return true;
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * SelectionPlugin manages cell/row/column/range selection state.
1016
+ * Handles keyboard navigation and selection extension.
1017
+ */
1018
+ class SelectionPlugin {
1019
+ constructor() {
1020
+ this.name = 'selection';
1021
+ this.priority = 5;
1022
+ this._engine = null;
1023
+ }
1024
+ onInit(engine) {
1025
+ this._engine = engine;
1026
+ }
1027
+ /** Select a single cell. */
1028
+ selectCell(row, col) {
1029
+ if (!this._engine)
1030
+ return;
1031
+ this._engine.selection.set({
1032
+ ...this._engine.selection(),
1033
+ activeCell: { row, col },
1034
+ range: { start: { row, col }, end: { row, col } },
1035
+ mode: 'cell',
1036
+ });
1037
+ }
1038
+ /** Select a rectangular range. */
1039
+ selectRange(start, end) {
1040
+ if (!this._engine)
1041
+ return;
1042
+ this._engine.selection.set({
1043
+ ...this._engine.selection(),
1044
+ range: { start, end },
1045
+ mode: 'range',
1046
+ });
1047
+ }
1048
+ /** Select an entire row. */
1049
+ selectRow(rowIndex) {
1050
+ if (!this._engine)
1051
+ return;
1052
+ const rows = new Set(this._engine.selection().selectedRows);
1053
+ if (rows.has(rowIndex)) {
1054
+ rows.delete(rowIndex);
1055
+ }
1056
+ else {
1057
+ rows.add(rowIndex);
1058
+ }
1059
+ this._engine.selection.set({
1060
+ ...this._engine.selection(),
1061
+ selectedRows: rows,
1062
+ mode: 'row',
1063
+ });
1064
+ }
1065
+ /** Select an entire column. */
1066
+ selectColumn(colIndex) {
1067
+ if (!this._engine)
1068
+ return;
1069
+ const cols = new Set(this._engine.selection().selectedCols);
1070
+ if (cols.has(colIndex)) {
1071
+ cols.delete(colIndex);
1072
+ }
1073
+ else {
1074
+ cols.add(colIndex);
1075
+ }
1076
+ this._engine.selection.set({
1077
+ ...this._engine.selection(),
1078
+ selectedCols: cols,
1079
+ mode: 'column',
1080
+ });
1081
+ }
1082
+ /** Extend the range selection to include the given cell. */
1083
+ extendSelection(row, col) {
1084
+ if (!this._engine)
1085
+ return;
1086
+ const sel = this._engine.selection();
1087
+ if (!sel.range) {
1088
+ this.selectRange(sel.activeCell ?? { row, col }, { row, col });
1089
+ }
1090
+ else {
1091
+ this.selectRange(sel.range.start, { row, col });
1092
+ }
1093
+ }
1094
+ /** Clear all selection. */
1095
+ clearSelection() {
1096
+ if (!this._engine)
1097
+ return;
1098
+ this._engine.selection.set({
1099
+ activeCell: null,
1100
+ range: null,
1101
+ selectedRows: new Set(),
1102
+ selectedCols: new Set(),
1103
+ mode: 'cell',
1104
+ dragging: false,
1105
+ });
1106
+ }
1107
+ /** Check if a cell is within the current selection. */
1108
+ isSelected(row, col) {
1109
+ const sel = this._engine?.selection();
1110
+ if (!sel)
1111
+ return false;
1112
+ if (sel.range) {
1113
+ const minRow = Math.min(sel.range.start.row, sel.range.end.row);
1114
+ const maxRow = Math.max(sel.range.start.row, sel.range.end.row);
1115
+ const minCol = Math.min(sel.range.start.col, sel.range.end.col);
1116
+ const maxCol = Math.max(sel.range.start.col, sel.range.end.col);
1117
+ if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol)
1118
+ return true;
1119
+ }
1120
+ if (sel.selectedRows.has(row))
1121
+ return true;
1122
+ if (sel.selectedCols.has(col))
1123
+ return true;
1124
+ return false;
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * FormulaPlugin evaluates formulas in cells.
1130
+ * Maintains a dependency graph for recalculation ordering.
1131
+ */
1132
+ class FormulaPlugin {
1133
+ constructor() {
1134
+ this.name = 'formula';
1135
+ this.priority = 50;
1136
+ this._engine = null;
1137
+ this._functions = new Map();
1138
+ this._registerBuiltinFunctions();
1139
+ }
1140
+ onInit(engine) {
1141
+ this._engine = engine;
1142
+ }
1143
+ onDestroy() {
1144
+ this._functions.clear();
1145
+ }
1146
+ /** Register a custom function. */
1147
+ registerFunction(fn) {
1148
+ this._functions.set(fn.name.toUpperCase(), fn);
1149
+ }
1150
+ /** Evaluate a formula string. */
1151
+ evaluate(formula, getCellValue) {
1152
+ try {
1153
+ const ast = this._parse(formula);
1154
+ if (!ast)
1155
+ return { value: null, error: '#PARSE!' };
1156
+ const value = this._evalNode(ast, getCellValue);
1157
+ return { value };
1158
+ }
1159
+ catch (e) {
1160
+ return { value: null, error: e instanceof Error ? e.message : '#ERROR!' };
1161
+ }
1162
+ }
1163
+ /** Build a dependency graph: which cells depend on which. */
1164
+ buildDependencyGraph() {
1165
+ const graph = new Map();
1166
+ if (!this._engine)
1167
+ return graph;
1168
+ const rows = this._engine.rowData();
1169
+ const cols = this._engine.columnDefs();
1170
+ for (let r = 0; r < rows.length; r++) {
1171
+ for (const col of cols) {
1172
+ const val = rows[r].cells[col.id]?.value;
1173
+ if (typeof val === 'string' && val.startsWith('=')) {
1174
+ const refs = this._extractRefs(val);
1175
+ graph.set(`${r}:${col.id}`, refs);
1176
+ }
1177
+ }
1178
+ }
1179
+ return graph;
1180
+ }
1181
+ /** Topologically sort cells by their dependencies (recalc order). */
1182
+ getRecalcOrder() {
1183
+ const graph = this.buildDependencyGraph();
1184
+ const visited = new Set();
1185
+ const order = [];
1186
+ const visit = (cell, stack) => {
1187
+ if (visited.has(cell))
1188
+ return;
1189
+ if (stack.has(cell))
1190
+ return;
1191
+ stack.add(cell);
1192
+ const deps = graph.get(cell) ?? [];
1193
+ for (const dep of deps) {
1194
+ visit(dep, stack);
1195
+ }
1196
+ stack.delete(cell);
1197
+ visited.add(cell);
1198
+ order.push(cell);
1199
+ };
1200
+ for (const cell of graph.keys()) {
1201
+ visit(cell, new Set());
1202
+ }
1203
+ return order;
1204
+ }
1205
+ // ── Parser ──
1206
+ _parse(formula) {
1207
+ const expr = formula.startsWith('=') ? formula.slice(1) : formula;
1208
+ try {
1209
+ return this._parseExpression(expr, { pos: 0 });
1210
+ }
1211
+ catch {
1212
+ return null;
1213
+ }
1214
+ }
1215
+ _parseExpression(expr, ctx) {
1216
+ return this._parseComparison(expr, ctx);
1217
+ }
1218
+ _parseComparison(expr, ctx) {
1219
+ let left = this._parseConcat(expr, ctx);
1220
+ this._skipSpaces(expr, ctx);
1221
+ while (ctx.pos < expr.length) {
1222
+ const ch = expr[ctx.pos];
1223
+ if (ch === '=' || ch === '<' || ch === '>') {
1224
+ let op;
1225
+ if (ch === '=' && expr[ctx.pos + 1] !== '=') {
1226
+ op = '=';
1227
+ ctx.pos++;
1228
+ }
1229
+ else if (ch === '<' && expr[ctx.pos + 1] === '>') {
1230
+ op = '<>';
1231
+ ctx.pos += 2;
1232
+ }
1233
+ else if (ch === '<' && expr[ctx.pos + 1] === '=') {
1234
+ op = '<=';
1235
+ ctx.pos += 2;
1236
+ }
1237
+ else if (ch === '>' && expr[ctx.pos + 1] === '=') {
1238
+ op = '>=';
1239
+ ctx.pos += 2;
1240
+ }
1241
+ else if (ch === '>') {
1242
+ op = '>';
1243
+ ctx.pos++;
1244
+ }
1245
+ else {
1246
+ break;
1247
+ }
1248
+ const right = this._parseConcat(expr, ctx);
1249
+ left = { type: 'binary_op', op: op, left, right };
1250
+ }
1251
+ else {
1252
+ break;
1253
+ }
1254
+ }
1255
+ return left;
1256
+ }
1257
+ _parseConcat(expr, ctx) {
1258
+ let left = this._parseAddSub(expr, ctx);
1259
+ this._skipSpaces(expr, ctx);
1260
+ while (ctx.pos < expr.length && expr[ctx.pos] === '&') {
1261
+ ctx.pos++;
1262
+ const right = this._parseAddSub(expr, ctx);
1263
+ left = { type: 'binary_op', op: '&', left, right };
1264
+ this._skipSpaces(expr, ctx);
1265
+ }
1266
+ return left;
1267
+ }
1268
+ _parseAddSub(expr, ctx) {
1269
+ let left = this._parseMulDiv(expr, ctx);
1270
+ this._skipSpaces(expr, ctx);
1271
+ while (ctx.pos < expr.length && (expr[ctx.pos] === '+' || expr[ctx.pos] === '-')) {
1272
+ const op = expr[ctx.pos];
1273
+ ctx.pos++;
1274
+ const right = this._parseMulDiv(expr, ctx);
1275
+ left = { type: 'binary_op', op, left, right };
1276
+ this._skipSpaces(expr, ctx);
1277
+ }
1278
+ return left;
1279
+ }
1280
+ _parseMulDiv(expr, ctx) {
1281
+ let left = this._parseUnary(expr, ctx);
1282
+ this._skipSpaces(expr, ctx);
1283
+ while (ctx.pos < expr.length && (expr[ctx.pos] === '*' || expr[ctx.pos] === '/')) {
1284
+ const op = expr[ctx.pos];
1285
+ ctx.pos++;
1286
+ const right = this._parseUnary(expr, ctx);
1287
+ left = { type: 'binary_op', op, left, right };
1288
+ this._skipSpaces(expr, ctx);
1289
+ }
1290
+ return left;
1291
+ }
1292
+ _parseUnary(expr, ctx) {
1293
+ this._skipSpaces(expr, ctx);
1294
+ if (ctx.pos < expr.length && (expr[ctx.pos] === '-' || expr[ctx.pos] === '+')) {
1295
+ const op = expr[ctx.pos];
1296
+ ctx.pos++;
1297
+ const operand = this._parseUnary(expr, ctx);
1298
+ return { type: 'unary_op', op, operand };
1299
+ }
1300
+ return this._parsePrimary(expr, ctx);
1301
+ }
1302
+ _parsePrimary(expr, ctx) {
1303
+ this._skipSpaces(expr, ctx);
1304
+ if (ctx.pos >= expr.length) {
1305
+ throw new Error('Unexpected end of expression');
1306
+ }
1307
+ const ch = expr[ctx.pos];
1308
+ // String literal
1309
+ if (ch === '"') {
1310
+ ctx.pos++;
1311
+ let str = '';
1312
+ while (ctx.pos < expr.length && expr[ctx.pos] !== '"') {
1313
+ str += expr[ctx.pos++];
1314
+ }
1315
+ if (ctx.pos < expr.length)
1316
+ ctx.pos++;
1317
+ return { type: 'string', value: str };
1318
+ }
1319
+ // Parenthesized expression
1320
+ if (ch === '(') {
1321
+ ctx.pos++;
1322
+ const node = this._parseExpression(expr, ctx);
1323
+ this._skipSpaces(expr, ctx);
1324
+ if (ctx.pos < expr.length && expr[ctx.pos] === ')')
1325
+ ctx.pos++;
1326
+ return node;
1327
+ }
1328
+ // Number
1329
+ if ((ch >= '0' && ch <= '9') || ch === '.') {
1330
+ let numStr = '';
1331
+ while (ctx.pos < expr.length &&
1332
+ ((expr[ctx.pos] >= '0' && expr[ctx.pos] <= '9') || expr[ctx.pos] === '.')) {
1333
+ numStr += expr[ctx.pos++];
1334
+ }
1335
+ return { type: 'number', value: parseFloat(numStr) };
1336
+ }
1337
+ // Boolean
1338
+ const upper = expr.slice(ctx.pos).toUpperCase();
1339
+ if (upper.startsWith('TRUE')) {
1340
+ ctx.pos += 4;
1341
+ return { type: 'boolean', value: true };
1342
+ }
1343
+ if (upper.startsWith('FALSE')) {
1344
+ ctx.pos += 5;
1345
+ return { type: 'boolean', value: false };
1346
+ }
1347
+ // Cell reference, range, or function call
1348
+ if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) {
1349
+ let name = '';
1350
+ while (ctx.pos < expr.length && /[A-Za-z0-9_]/.test(expr[ctx.pos])) {
1351
+ name += expr[ctx.pos++];
1352
+ }
1353
+ this._skipSpaces(expr, ctx);
1354
+ // Range reference (e.g. A1:B5)
1355
+ if (ctx.pos < expr.length && expr[ctx.pos] === ':') {
1356
+ ctx.pos++;
1357
+ const end = this._parseCellRef(expr, ctx);
1358
+ const startParsed = parseCellRefStr(name);
1359
+ return {
1360
+ type: 'range_ref',
1361
+ startCol: startParsed.col,
1362
+ startRow: startParsed.row,
1363
+ endCol: end.col,
1364
+ endRow: end.row,
1365
+ };
1366
+ }
1367
+ // Function call
1368
+ if (ctx.pos < expr.length && expr[ctx.pos] === '(') {
1369
+ ctx.pos++;
1370
+ const args = [];
1371
+ while (ctx.pos < expr.length && expr[ctx.pos] !== ')') {
1372
+ args.push(this._parseExpression(expr, ctx));
1373
+ this._skipSpaces(expr, ctx);
1374
+ if (ctx.pos < expr.length && expr[ctx.pos] === ',')
1375
+ ctx.pos++;
1376
+ this._skipSpaces(expr, ctx);
1377
+ }
1378
+ if (ctx.pos < expr.length)
1379
+ ctx.pos++;
1380
+ return { type: 'function_call', name: name.toUpperCase(), args };
1381
+ }
1382
+ // Cell reference
1383
+ const ref = parseCellRefStr(name);
1384
+ return { type: 'cell_ref', col: ref.col, row: ref.row };
1385
+ }
1386
+ throw new Error(`Unexpected character: ${ch}`);
1387
+ }
1388
+ _parseCellRef(expr, ctx) {
1389
+ let name = '';
1390
+ while (ctx.pos < expr.length && /[A-Za-z0-9_]/.test(expr[ctx.pos])) {
1391
+ name += expr[ctx.pos++];
1392
+ }
1393
+ return parseCellRefStr(name);
1394
+ }
1395
+ _skipSpaces(expr, ctx) {
1396
+ while (ctx.pos < expr.length && expr[ctx.pos] === ' ')
1397
+ ctx.pos++;
1398
+ }
1399
+ // ── Evaluator ──
1400
+ _evalNode(node, getCellValue) {
1401
+ switch (node.type) {
1402
+ case 'number':
1403
+ return node.value;
1404
+ case 'string':
1405
+ return node.value;
1406
+ case 'boolean':
1407
+ return node.value;
1408
+ case 'cell_ref': {
1409
+ const refNode = node;
1410
+ const ref = `${refNode.col}${refNode.row}`;
1411
+ const val = getCellValue(ref);
1412
+ if (typeof val === 'string' && val.startsWith('=')) {
1413
+ const result = this.evaluate(val, getCellValue);
1414
+ return result.value;
1415
+ }
1416
+ return val;
1417
+ }
1418
+ case 'binary_op': {
1419
+ const binNode = node;
1420
+ const l = this._evalNode(binNode.left, getCellValue);
1421
+ const r = this._evalNode(binNode.right, getCellValue);
1422
+ return this._evalBinaryOp(binNode.op, l, r);
1423
+ }
1424
+ case 'unary_op': {
1425
+ const unaryNode = node;
1426
+ const val = this._evalNode(unaryNode.operand, getCellValue);
1427
+ if (unaryNode.op === '-')
1428
+ return -val;
1429
+ return val;
1430
+ }
1431
+ case 'function_call': {
1432
+ const fnNode = node;
1433
+ const fn = this._functions.get(fnNode.name);
1434
+ if (!fn)
1435
+ throw new Error(`Unknown function: ${fnNode.name}`);
1436
+ const args = fnNode.args.map((a) => this._evalNode(a, getCellValue));
1437
+ return fn.fn(args);
1438
+ }
1439
+ case 'range_ref':
1440
+ return 0;
1441
+ default:
1442
+ return null;
1443
+ }
1444
+ }
1445
+ _evalBinaryOp(op, l, r) {
1446
+ const ln = typeof l === 'number' ? l : parseFloat(String(l)) || 0;
1447
+ const rn = typeof r === 'number' ? r : parseFloat(String(r)) || 0;
1448
+ switch (op) {
1449
+ case '+':
1450
+ return ln + rn;
1451
+ case '-':
1452
+ return ln - rn;
1453
+ case '*':
1454
+ return ln * rn;
1455
+ case '/':
1456
+ return rn !== 0 ? ln / rn : null;
1457
+ case '&':
1458
+ return String(l ?? '') + String(r ?? '');
1459
+ case '=':
1460
+ return l === r || ln === rn;
1461
+ case '<>':
1462
+ return l !== r && ln !== rn;
1463
+ case '>':
1464
+ return ln > rn;
1465
+ case '<':
1466
+ return ln < rn;
1467
+ case '>=':
1468
+ return ln >= rn;
1469
+ case '<=':
1470
+ return ln <= rn;
1471
+ default:
1472
+ return 0;
1473
+ }
1474
+ }
1475
+ _extractRefs(formula) {
1476
+ const refs = [];
1477
+ const regex = /([A-Z]+)(\d+)/gi;
1478
+ let match;
1479
+ while ((match = regex.exec(formula)) !== null) {
1480
+ refs.push(`${match[1].toUpperCase()}${match[2]}`);
1481
+ }
1482
+ return refs;
1483
+ }
1484
+ // ── Built-in Functions ──
1485
+ _registerBuiltinFunctions() {
1486
+ const fns = [
1487
+ {
1488
+ name: 'SUM',
1489
+ minArgs: 1,
1490
+ maxArgs: Infinity,
1491
+ fn: (args) => args.reduce((s, v) => s + (Number(v) || 0), 0),
1492
+ },
1493
+ {
1494
+ name: 'AVERAGE',
1495
+ minArgs: 1,
1496
+ maxArgs: Infinity,
1497
+ fn: (args) => {
1498
+ const nums = args.map(Number).filter((n) => !isNaN(n));
1499
+ return nums.length ? nums.reduce((s, v) => s + v, 0) / nums.length : 0;
1500
+ },
1501
+ },
1502
+ {
1503
+ name: 'COUNT',
1504
+ minArgs: 1,
1505
+ maxArgs: Infinity,
1506
+ fn: (args) => args.filter((v) => v != null && v !== '').length,
1507
+ },
1508
+ {
1509
+ name: 'MIN',
1510
+ minArgs: 1,
1511
+ maxArgs: Infinity,
1512
+ fn: (args) => Math.min(...args.map(Number).filter((n) => !isNaN(n))),
1513
+ },
1514
+ {
1515
+ name: 'MAX',
1516
+ minArgs: 1,
1517
+ maxArgs: Infinity,
1518
+ fn: (args) => Math.max(...args.map(Number).filter((n) => !isNaN(n))),
1519
+ },
1520
+ {
1521
+ name: 'IF',
1522
+ minArgs: 3,
1523
+ maxArgs: 3,
1524
+ fn: (args) => (args[0] ? args[1] : args[2]),
1525
+ },
1526
+ {
1527
+ name: 'AND',
1528
+ minArgs: 1,
1529
+ maxArgs: Infinity,
1530
+ fn: (args) => args.every(Boolean),
1531
+ },
1532
+ {
1533
+ name: 'OR',
1534
+ minArgs: 1,
1535
+ maxArgs: Infinity,
1536
+ fn: (args) => args.some(Boolean),
1537
+ },
1538
+ {
1539
+ name: 'NOT',
1540
+ minArgs: 1,
1541
+ maxArgs: 1,
1542
+ fn: (args) => !args[0],
1543
+ },
1544
+ {
1545
+ name: 'CONCAT',
1546
+ minArgs: 1,
1547
+ maxArgs: Infinity,
1548
+ fn: (args) => args.join(''),
1549
+ },
1550
+ {
1551
+ name: 'ROUND',
1552
+ minArgs: 1,
1553
+ maxArgs: 2,
1554
+ fn: (args) => {
1555
+ const decimals = args[1] != null ? Number(args[1]) : 0;
1556
+ return Math.round(Number(args[0]) * Math.pow(10, decimals)) / Math.pow(10, decimals);
1557
+ },
1558
+ },
1559
+ { name: 'ABS', minArgs: 1, maxArgs: 1, fn: (args) => Math.abs(Number(args[0])) },
1560
+ {
1561
+ name: 'POWER',
1562
+ minArgs: 2,
1563
+ maxArgs: 2,
1564
+ fn: (args) => Math.pow(Number(args[0]), Number(args[1])),
1565
+ },
1566
+ { name: 'SQRT', minArgs: 1, maxArgs: 1, fn: (args) => Math.sqrt(Number(args[0])) },
1567
+ { name: 'LEN', minArgs: 1, maxArgs: 1, fn: (args) => String(args[0]).length },
1568
+ { name: 'UPPER', minArgs: 1, maxArgs: 1, fn: (args) => String(args[0]).toUpperCase() },
1569
+ { name: 'LOWER', minArgs: 1, maxArgs: 1, fn: (args) => String(args[0]).toLowerCase() },
1570
+ { name: 'TRIM', minArgs: 1, maxArgs: 1, fn: (args) => String(args[0]).trim() },
1571
+ ];
1572
+ for (const fn of fns) {
1573
+ this._functions.set(fn.name, fn);
1574
+ }
1575
+ }
1576
+ }
1577
+ function parseCellRefStr(ref) {
1578
+ const match = ref.match(/^([A-Z]+)(\d+)$/i);
1579
+ if (!match)
1580
+ throw new Error(`Invalid cell reference: ${ref}`);
1581
+ return { col: match[1].toUpperCase(), row: parseInt(match[2], 10) };
1582
+ }
1583
+
1584
+ /**
1585
+ * ClipboardPlugin handles copy/paste/cut operations.
1586
+ * Reads from and writes to the system clipboard via the Clipboard API.
1587
+ */
1588
+ class ClipboardPlugin {
1589
+ constructor() {
1590
+ this.name = 'clipboard';
1591
+ this.priority = 30;
1592
+ this._engine = null;
1593
+ this._cutCells = null;
1594
+ }
1595
+ onInit(engine) {
1596
+ this._engine = engine;
1597
+ }
1598
+ /** Copy the current selection to the clipboard. */
1599
+ async copy() {
1600
+ if (!this._engine)
1601
+ return;
1602
+ const sel = this._engine.selection();
1603
+ if (!sel.range)
1604
+ return;
1605
+ const tsv = this._selectionToTsv(sel.range.start.row, sel.range.start.col, sel.range.end.row, sel.range.end.col);
1606
+ try {
1607
+ await navigator.clipboard.writeText(tsv);
1608
+ }
1609
+ catch {
1610
+ // Fallback: use a temporary textarea
1611
+ this._copyToTextarea(tsv);
1612
+ }
1613
+ }
1614
+ /** Cut the current selection (copy + clear). */
1615
+ async cut() {
1616
+ if (!this._engine)
1617
+ return;
1618
+ const sel = this._engine.selection();
1619
+ if (!sel.range)
1620
+ return;
1621
+ const startRow = Math.min(sel.range.start.row, sel.range.end.row);
1622
+ const startCol = Math.min(sel.range.start.col, sel.range.end.col);
1623
+ const endRow = Math.max(sel.range.start.row, sel.range.end.row);
1624
+ const endCol = Math.max(sel.range.start.col, sel.range.end.col);
1625
+ this._cutCells = [];
1626
+ const rows = [...this._engine.rowData()];
1627
+ const cols = this._engine.columnDefs();
1628
+ for (let r = startRow; r <= endRow; r++) {
1629
+ for (let c = startCol; c <= endCol; c++) {
1630
+ const colId = cols[c]?.id;
1631
+ if (colId) {
1632
+ this._cutCells.push({ row: r, col: c, value: rows[r]?.cells[colId]?.value ?? null });
1633
+ }
1634
+ }
1635
+ }
1636
+ await this.copy();
1637
+ this.clearSelectionContent();
1638
+ }
1639
+ /** Paste clipboard content starting at the active cell. */
1640
+ async paste() {
1641
+ if (!this._engine)
1642
+ return;
1643
+ const sel = this._engine.selection();
1644
+ const activeCell = sel.activeCell;
1645
+ if (!activeCell)
1646
+ return;
1647
+ let text;
1648
+ try {
1649
+ text = await navigator.clipboard.readText();
1650
+ }
1651
+ catch {
1652
+ return;
1653
+ }
1654
+ if (!text)
1655
+ return;
1656
+ const lines = text.split('\n').filter((l) => l.length > 0);
1657
+ const cols = this._engine.columnDefs();
1658
+ const rows = [...this._engine.rowData()];
1659
+ const startRow = activeCell.row;
1660
+ const startCol = activeCell.col;
1661
+ for (let r = 0; r < lines.length; r++) {
1662
+ const cells = lines[r].split('\t');
1663
+ const targetRow = startRow + r;
1664
+ if (targetRow >= rows.length)
1665
+ break;
1666
+ for (let c = 0; c < cells.length; c++) {
1667
+ const targetCol = startCol + c;
1668
+ if (targetCol >= cols.length)
1669
+ break;
1670
+ const colId = cols[targetCol].id;
1671
+ const rowData = rows[targetRow];
1672
+ if (rowData) {
1673
+ const raw = cells[c].trim();
1674
+ const value = parseClipboardValue(raw);
1675
+ rows[targetRow] = {
1676
+ ...rowData,
1677
+ cells: { ...rowData.cells, [colId]: { value, meta: rowData.cells[colId]?.meta } },
1678
+ };
1679
+ }
1680
+ }
1681
+ }
1682
+ this._engine.rowData.set(rows);
1683
+ }
1684
+ /** Clear the content of selected cells. */
1685
+ clearSelectionContent() {
1686
+ if (!this._engine)
1687
+ return;
1688
+ const sel = this._engine.selection();
1689
+ if (!sel.range)
1690
+ return;
1691
+ const startRow = Math.min(sel.range.start.row, sel.range.end.row);
1692
+ const startCol = Math.min(sel.range.start.col, sel.range.end.col);
1693
+ const endRow = Math.max(sel.range.start.row, sel.range.end.row);
1694
+ const endCol = Math.max(sel.range.start.col, sel.range.end.col);
1695
+ const rows = [...this._engine.rowData()];
1696
+ const cols = this._engine.columnDefs();
1697
+ for (let r = startRow; r <= endRow; r++) {
1698
+ for (let c = startCol; c <= endCol; c++) {
1699
+ const colId = cols[c]?.id;
1700
+ if (colId && rows[r]) {
1701
+ rows[r] = {
1702
+ ...rows[r],
1703
+ cells: { ...rows[r].cells, [colId]: { value: null, meta: rows[r].cells[colId]?.meta } },
1704
+ };
1705
+ }
1706
+ }
1707
+ }
1708
+ this._engine.rowData.set(rows);
1709
+ }
1710
+ _selectionToTsv(startRow, startCol, endRow, endCol) {
1711
+ if (!this._engine)
1712
+ return '';
1713
+ const rows = this._engine.rowData();
1714
+ const cols = this._engine.columnDefs();
1715
+ const minRow = Math.min(startRow, endRow);
1716
+ const maxRow = Math.max(startRow, endRow);
1717
+ const minCol = Math.min(startCol, endCol);
1718
+ const maxCol = Math.max(startCol, endCol);
1719
+ const lines = [];
1720
+ for (let r = minRow; r <= maxRow; r++) {
1721
+ const cells = [];
1722
+ for (let c = minCol; c <= maxCol; c++) {
1723
+ const colId = cols[c]?.id;
1724
+ const val = colId ? rows[r]?.cells[colId]?.value : null;
1725
+ cells.push(val == null ? '' : String(val));
1726
+ }
1727
+ lines.push(cells.join('\t'));
1728
+ }
1729
+ return lines.join('\n');
1730
+ }
1731
+ _copyToTextarea(text) {
1732
+ const ta = document.createElement('textarea');
1733
+ ta.value = text;
1734
+ ta.style.position = 'fixed';
1735
+ ta.style.left = '-9999px';
1736
+ document.body.appendChild(ta);
1737
+ ta.select();
1738
+ document.execCommand('copy');
1739
+ document.body.removeChild(ta);
1740
+ }
1741
+ }
1742
+ function parseClipboardValue(raw) {
1743
+ if (raw === '')
1744
+ return null;
1745
+ if (raw === 'TRUE')
1746
+ return true;
1747
+ if (raw === 'FALSE')
1748
+ return false;
1749
+ const num = Number(raw);
1750
+ if (!isNaN(num) && raw !== '')
1751
+ return num;
1752
+ return raw;
1753
+ }
1754
+
1755
+ /**
1756
+ * UndoPlugin provides undo/redo by maintaining a history stack.
1757
+ * Captures snapshots before mutations.
1758
+ */
1759
+ class UndoPlugin {
1760
+ constructor() {
1761
+ this.name = 'undo';
1762
+ this.priority = 100;
1763
+ this._engine = null;
1764
+ this._undoStack = [];
1765
+ this._redoStack = [];
1766
+ this._maxHistory = 100;
1767
+ }
1768
+ onInit(engine) {
1769
+ this._engine = engine;
1770
+ }
1771
+ onDestroy() {
1772
+ this._undoStack = [];
1773
+ this._redoStack = [];
1774
+ }
1775
+ /** Record an edit mutation for undo. */
1776
+ recordEdit(rowIndex, colId, oldValue, newValue) {
1777
+ this._push({
1778
+ type: 'edit',
1779
+ timestamp: Date.now(),
1780
+ edits: [{ rowIndex, colId, oldValue, newValue }],
1781
+ });
1782
+ }
1783
+ /** Record a bulk edit (multiple cells). */
1784
+ recordBulkEdit(edits) {
1785
+ if (edits.length === 0)
1786
+ return;
1787
+ this._push({ type: 'edit', timestamp: Date.now(), edits });
1788
+ }
1789
+ /** Undo the last mutation. */
1790
+ undo() {
1791
+ if (!this._engine || this._undoStack.length === 0)
1792
+ return false;
1793
+ const entry = this._undoStack.pop();
1794
+ this._applyUndo(entry);
1795
+ this._redoStack.push(entry);
1796
+ return true;
1797
+ }
1798
+ /** Redo the last undone mutation. */
1799
+ redo() {
1800
+ if (!this._engine || this._redoStack.length === 0)
1801
+ return false;
1802
+ const entry = this._redoStack.pop();
1803
+ this._applyRedo(entry);
1804
+ this._undoStack.push(entry);
1805
+ return true;
1806
+ }
1807
+ /** Whether undo is available. */
1808
+ get canUndo() {
1809
+ return this._undoStack.length > 0;
1810
+ }
1811
+ /** Whether redo is available. */
1812
+ get canRedo() {
1813
+ return this._redoStack.length > 0;
1814
+ }
1815
+ /** Clear all history. */
1816
+ clearHistory() {
1817
+ this._undoStack = [];
1818
+ this._redoStack = [];
1819
+ }
1820
+ _push(entry) {
1821
+ this._undoStack.push(entry);
1822
+ if (this._undoStack.length > this._maxHistory) {
1823
+ this._undoStack.shift();
1824
+ }
1825
+ this._redoStack = [];
1826
+ }
1827
+ _applyUndo(entry) {
1828
+ if (!this._engine)
1829
+ return;
1830
+ if (entry.type === 'edit' && entry.edits) {
1831
+ const rows = [...this._engine.rowData()];
1832
+ for (const edit of entry.edits) {
1833
+ if (rows[edit.rowIndex]) {
1834
+ rows[edit.rowIndex] = {
1835
+ ...rows[edit.rowIndex],
1836
+ cells: {
1837
+ ...rows[edit.rowIndex].cells,
1838
+ [edit.colId]: {
1839
+ value: edit.oldValue,
1840
+ meta: rows[edit.rowIndex].cells[edit.colId]?.meta,
1841
+ },
1842
+ },
1843
+ };
1844
+ }
1845
+ }
1846
+ this._engine.rowData.set(rows);
1847
+ }
1848
+ else if (entry.type === 'delete_rows' && entry.rows) {
1849
+ const rows = [...this._engine.rowData()];
1850
+ rows.splice(entry.rowIndex ?? 0, 0, ...entry.rows);
1851
+ this._engine.rowData.set(rows);
1852
+ }
1853
+ else if (entry.type === 'insert_rows' && entry.rowIndex != null) {
1854
+ const rows = [...this._engine.rowData()];
1855
+ rows.splice(entry.rowIndex, entry.rows?.length ?? 0);
1856
+ this._engine.rowData.set(rows);
1857
+ }
1858
+ }
1859
+ _applyRedo(entry) {
1860
+ if (!this._engine)
1861
+ return;
1862
+ if (entry.type === 'edit' && entry.edits) {
1863
+ const rows = [...this._engine.rowData()];
1864
+ for (const edit of entry.edits) {
1865
+ if (rows[edit.rowIndex]) {
1866
+ rows[edit.rowIndex] = {
1867
+ ...rows[edit.rowIndex],
1868
+ cells: {
1869
+ ...rows[edit.rowIndex].cells,
1870
+ [edit.colId]: {
1871
+ value: edit.newValue,
1872
+ meta: rows[edit.rowIndex].cells[edit.colId]?.meta,
1873
+ },
1874
+ },
1875
+ };
1876
+ }
1877
+ }
1878
+ this._engine.rowData.set(rows);
1879
+ }
1880
+ else if (entry.type === 'insert_rows' && entry.rows) {
1881
+ const rows = [...this._engine.rowData()];
1882
+ rows.splice(entry.rowIndex ?? 0, 0, ...entry.rows);
1883
+ this._engine.rowData.set(rows);
1884
+ }
1885
+ else if (entry.type === 'delete_rows' && entry.rowIndex != null) {
1886
+ const rows = [...this._engine.rowData()];
1887
+ rows.splice(entry.rowIndex, entry.rows?.length ?? 0);
1888
+ this._engine.rowData.set(rows);
1889
+ }
1890
+ }
1891
+ }
1892
+
1893
+ /**
1894
+ * ValidationPlugin validates cell values against registered rules.
1895
+ */
1896
+ class ValidationPlugin {
1897
+ constructor() {
1898
+ this.name = 'validation';
1899
+ this.priority = 40;
1900
+ this._engine = null;
1901
+ this._rules = new Map();
1902
+ }
1903
+ onInit(engine) {
1904
+ this._engine = engine;
1905
+ }
1906
+ /** Register a validation rule for a column. */
1907
+ addRule(rule) {
1908
+ this._rules.set(rule.colId, rule.validator);
1909
+ }
1910
+ /** Remove a validation rule. */
1911
+ removeRule(colId) {
1912
+ this._rules.delete(colId);
1913
+ }
1914
+ /** Validate a single cell value. Returns null if valid, error string if not. */
1915
+ validate(colId, value) {
1916
+ const rule = this._rules.get(colId);
1917
+ return rule ? rule(value) : null;
1918
+ }
1919
+ /** Validate all cells and return errors indexed by "row:colId". */
1920
+ validateAll() {
1921
+ const errors = new Map();
1922
+ if (!this._engine)
1923
+ return errors;
1924
+ const rows = this._engine.rowData();
1925
+ const cols = this._engine.columnDefs();
1926
+ for (let r = 0; r < rows.length; r++) {
1927
+ for (const col of cols) {
1928
+ const val = rows[r].cells[col.id]?.value;
1929
+ const err = this.validate(col.id, val);
1930
+ if (err) {
1931
+ const key = `${r}:${col.id}`;
1932
+ errors.set(key, [err]);
1933
+ }
1934
+ }
1935
+ }
1936
+ return errors;
1937
+ }
1938
+ onBeforeEdit(_rowIndex, _colId) {
1939
+ return true;
1940
+ }
1941
+ onAfterEdit(rowIndex, colId, _oldValue, newValue) {
1942
+ const err = this.validate(colId, newValue);
1943
+ if (err && this._engine) {
1944
+ const rows = [...this._engine.rowData()];
1945
+ const row = rows[rowIndex];
1946
+ if (row) {
1947
+ const cell = row.cells[colId];
1948
+ rows[rowIndex] = {
1949
+ ...row,
1950
+ cells: {
1951
+ ...row.cells,
1952
+ [colId]: {
1953
+ value: newValue,
1954
+ meta: { ...cell?.meta, errors: [err], type: cell?.meta?.type ?? 'text' },
1955
+ },
1956
+ },
1957
+ };
1958
+ this._engine.rowData.set(rows);
1959
+ }
1960
+ }
1961
+ }
1962
+ }
1963
+
1964
+ /**
1965
+ * Generated bundle index. Do not edit.
1966
+ */
1967
+
1968
+ export { ClipboardPlugin, FilterPlugin, FormulaPlugin, PluginHost, SelectionPlugin, SortPlugin, SpreadsheetEngine, UndoPlugin, ValidationPlugin, cellRef, cellsInRange, cloneRow, colIndexToLetter, colLetterToIndex, createEmptyRow, formatCellValue, generateRowId, isCellInRange, normalizeRange, parseCellRef, pinnedWidth };
1969
+ //# sourceMappingURL=ngxsmk-cdk-spreadsheet.mjs.map