@pagent-libs/core 0.1.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 (111) hide show
  1. package/README.md +32 -0
  2. package/dist/canvas/cell-renderer.d.ts +45 -0
  3. package/dist/canvas/cell-renderer.d.ts.map +1 -0
  4. package/dist/canvas/grid-renderer.d.ts +29 -0
  5. package/dist/canvas/grid-renderer.d.ts.map +1 -0
  6. package/dist/canvas/header-renderer.d.ts +58 -0
  7. package/dist/canvas/header-renderer.d.ts.map +1 -0
  8. package/dist/canvas/hit-testing.d.ts +81 -0
  9. package/dist/canvas/hit-testing.d.ts.map +1 -0
  10. package/dist/canvas/index.d.ts +9 -0
  11. package/dist/canvas/index.d.ts.map +1 -0
  12. package/dist/canvas/renderer.d.ts +140 -0
  13. package/dist/canvas/renderer.d.ts.map +1 -0
  14. package/dist/canvas/selection-renderer.d.ts +55 -0
  15. package/dist/canvas/selection-renderer.d.ts.map +1 -0
  16. package/dist/canvas/text-renderer.d.ts +49 -0
  17. package/dist/canvas/text-renderer.d.ts.map +1 -0
  18. package/dist/canvas/types.d.ts +200 -0
  19. package/dist/canvas/types.d.ts.map +1 -0
  20. package/dist/collaboration/firebase-provider.d.ts +13 -0
  21. package/dist/collaboration/firebase-provider.d.ts.map +1 -0
  22. package/dist/collaboration/index.d.ts +3 -0
  23. package/dist/collaboration/index.d.ts.map +1 -0
  24. package/dist/collaboration/types.d.ts +34 -0
  25. package/dist/collaboration/types.d.ts.map +1 -0
  26. package/dist/event-emitter.d.ts +13 -0
  27. package/dist/event-emitter.d.ts.map +1 -0
  28. package/dist/export/csv.d.ts +5 -0
  29. package/dist/export/csv.d.ts.map +1 -0
  30. package/dist/export/index.d.ts +2 -0
  31. package/dist/export/index.d.ts.map +1 -0
  32. package/dist/features/filter.d.ts +58 -0
  33. package/dist/features/filter.d.ts.map +1 -0
  34. package/dist/features/freeze.d.ts +86 -0
  35. package/dist/features/freeze.d.ts.map +1 -0
  36. package/dist/features/index.d.ts +4 -0
  37. package/dist/features/index.d.ts.map +1 -0
  38. package/dist/features/sort.d.ts +15 -0
  39. package/dist/features/sort.d.ts.map +1 -0
  40. package/dist/format-pool.d.ts +17 -0
  41. package/dist/format-pool.d.ts.map +1 -0
  42. package/dist/formula-graph.d.ts +12 -0
  43. package/dist/formula-graph.d.ts.map +1 -0
  44. package/dist/formula-parser/cell-reference.d.ts +7 -0
  45. package/dist/formula-parser/cell-reference.d.ts.map +1 -0
  46. package/dist/formula-parser/formula-adjust.d.ts +13 -0
  47. package/dist/formula-parser/formula-adjust.d.ts.map +1 -0
  48. package/dist/formula-parser/formula-ranges.d.ts +22 -0
  49. package/dist/formula-parser/formula-ranges.d.ts.map +1 -0
  50. package/dist/formula-parser/index.d.ts +6 -0
  51. package/dist/formula-parser/index.d.ts.map +1 -0
  52. package/dist/formula-parser/parser.d.ts +18 -0
  53. package/dist/formula-parser/parser.d.ts.map +1 -0
  54. package/dist/formula-parser/types.d.ts +33 -0
  55. package/dist/formula-parser/types.d.ts.map +1 -0
  56. package/dist/index.d.ts +15 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.esm.js +5823 -0
  59. package/dist/index.esm.js.map +1 -0
  60. package/dist/index.js +5885 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/sheet.d.ts +119 -0
  63. package/dist/sheet.d.ts.map +1 -0
  64. package/dist/style-pool.d.ts +17 -0
  65. package/dist/style-pool.d.ts.map +1 -0
  66. package/dist/types.d.ts +260 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/utils/cell-key.d.ts +7 -0
  69. package/dist/utils/cell-key.d.ts.map +1 -0
  70. package/dist/utils/format-utils.d.ts +75 -0
  71. package/dist/utils/format-utils.d.ts.map +1 -0
  72. package/dist/utils/range.d.ts +13 -0
  73. package/dist/utils/range.d.ts.map +1 -0
  74. package/dist/workbook.d.ts +155 -0
  75. package/dist/workbook.d.ts.map +1 -0
  76. package/package.json +46 -0
  77. package/src/canvas/cell-renderer.ts +181 -0
  78. package/src/canvas/grid-renderer.ts +238 -0
  79. package/src/canvas/header-renderer.ts +402 -0
  80. package/src/canvas/hit-testing.ts +537 -0
  81. package/src/canvas/index.ts +16 -0
  82. package/src/canvas/renderer.ts +1056 -0
  83. package/src/canvas/selection-renderer.ts +604 -0
  84. package/src/canvas/text-renderer.ts +321 -0
  85. package/src/canvas/types.ts +289 -0
  86. package/src/collaboration/firebase-provider.ts +48 -0
  87. package/src/collaboration/index.ts +5 -0
  88. package/src/collaboration/types.ts +38 -0
  89. package/src/event-emitter.ts +73 -0
  90. package/src/export/csv.ts +101 -0
  91. package/src/export/index.ts +4 -0
  92. package/src/features/filter.ts +231 -0
  93. package/src/features/freeze.ts +271 -0
  94. package/src/features/index.ts +5 -0
  95. package/src/features/sort.ts +282 -0
  96. package/src/format-pool.ts +61 -0
  97. package/src/formula-graph.ts +84 -0
  98. package/src/formula-parser/cell-reference.ts +99 -0
  99. package/src/formula-parser/formula-adjust.ts +129 -0
  100. package/src/formula-parser/formula-ranges.ts +159 -0
  101. package/src/formula-parser/index.ts +8 -0
  102. package/src/formula-parser/parser.ts +438 -0
  103. package/src/formula-parser/types.ts +39 -0
  104. package/src/index.ts +25 -0
  105. package/src/sheet.ts +502 -0
  106. package/src/style-pool.ts +62 -0
  107. package/src/types.ts +291 -0
  108. package/src/utils/cell-key.ts +19 -0
  109. package/src/utils/format-utils.ts +515 -0
  110. package/src/utils/range.ts +53 -0
  111. package/src/workbook.ts +1031 -0
@@ -0,0 +1,73 @@
1
+ // Event emitter for workbook events
2
+
3
+ import type { EventData, EventHandler, EventType } from './types';
4
+
5
+ export class EventEmitter {
6
+ private handlers: Map<EventType, Set<EventHandler>> = new Map();
7
+ private batchQueue: EventData[] = [];
8
+ private isBatching = false;
9
+
10
+ on(event: EventType, handler: EventHandler): () => void {
11
+ if (!this.handlers.has(event)) {
12
+ this.handlers.set(event, new Set());
13
+ }
14
+ this.handlers.get(event)!.add(handler);
15
+
16
+ // Return unsubscribe function
17
+ return () => {
18
+ this.handlers.get(event)?.delete(handler);
19
+ };
20
+ }
21
+
22
+ off(event: EventType, handler: EventHandler): void {
23
+ this.handlers.get(event)?.delete(handler);
24
+ }
25
+
26
+ emit(event: EventType, payload: unknown): void {
27
+ const data: EventData = { type: event, payload };
28
+
29
+ if (this.isBatching) {
30
+ this.batchQueue.push(data);
31
+ return;
32
+ }
33
+
34
+ this.dispatch(data);
35
+ }
36
+
37
+ private dispatch(data: EventData): void {
38
+ const handlers = this.handlers.get(data.type);
39
+ if (handlers) {
40
+ for (const handler of handlers) {
41
+ try {
42
+ handler(data);
43
+ } catch (error) {
44
+ console.error('Error in event handler:', error);
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ batch(operations: () => void): void {
51
+ this.isBatching = true;
52
+ this.batchQueue = [];
53
+
54
+ try {
55
+ operations();
56
+ } finally {
57
+ this.isBatching = false;
58
+ const events = [...this.batchQueue];
59
+ this.batchQueue = [];
60
+
61
+ // Dispatch all batched events
62
+ for (const event of events) {
63
+ this.dispatch(event);
64
+ }
65
+ }
66
+ }
67
+
68
+ clear(): void {
69
+ this.handlers.clear();
70
+ this.batchQueue = [];
71
+ }
72
+ }
73
+
@@ -0,0 +1,101 @@
1
+ import type { Sheet } from '../types';
2
+ import type { WorkbookImpl } from '../workbook';
3
+
4
+ export function exportToCSV(workbook: WorkbookImpl, sheetId?: string): string {
5
+ const sheet = workbook.getSheet(sheetId);
6
+ const rows: string[][] = [];
7
+
8
+ // Find the maximum row and column with data
9
+ let maxRow = 0;
10
+ let maxCol = 0;
11
+
12
+ for (const [key] of sheet.cells) {
13
+ const [row, col] = key.split(':').map(Number);
14
+ maxRow = Math.max(maxRow, row);
15
+ maxCol = Math.max(maxCol, col);
16
+ }
17
+
18
+ // Generate CSV rows
19
+ for (let row = 0; row <= maxRow; row++) {
20
+ const csvRow: string[] = [];
21
+ for (let col = 0; col <= maxCol; col++) {
22
+ const cell = sheet.getCell(row, col);
23
+ let value = '';
24
+
25
+ if (cell) {
26
+ if (cell.formula) {
27
+ // For formulas, export the formula itself (or could export calculated value)
28
+ value = cell.formula;
29
+ } else {
30
+ value = cell.value?.toString() || '';
31
+ }
32
+ }
33
+
34
+ // Escape CSV value
35
+ if (value.includes(',') || value.includes('"') || value.includes('\n')) {
36
+ value = `"${value.replace(/"/g, '""')}"`;
37
+ }
38
+
39
+ csvRow.push(value);
40
+ }
41
+ rows.push(csvRow);
42
+ }
43
+
44
+ return rows.map((row) => row.join(',')).join('\n');
45
+ }
46
+
47
+ export function importFromCSV(csv: string, sheet: Sheet): void {
48
+ const lines = csv.split('\n');
49
+ let row = 0;
50
+
51
+ for (const line of lines) {
52
+ if (!line.trim()) {
53
+ row++;
54
+ continue;
55
+ }
56
+
57
+ // Simple CSV parsing (doesn't handle all edge cases)
58
+ const values = parseCSVLine(line);
59
+ values.forEach((value, col) => {
60
+ if (value.trim()) {
61
+ sheet.setCellValue(row, col, value);
62
+ }
63
+ });
64
+
65
+ row++;
66
+ }
67
+ }
68
+
69
+ function parseCSVLine(line: string): string[] {
70
+ const values: string[] = [];
71
+ let current = '';
72
+ let inQuotes = false;
73
+
74
+ for (let i = 0; i < line.length; i++) {
75
+ const char = line[i];
76
+ const nextChar = line[i + 1];
77
+
78
+ if (char === '"') {
79
+ if (inQuotes && nextChar === '"') {
80
+ // Escaped quote
81
+ current += '"';
82
+ i++; // Skip next quote
83
+ } else {
84
+ // Toggle quote state
85
+ inQuotes = !inQuotes;
86
+ }
87
+ } else if (char === ',' && !inQuotes) {
88
+ // End of value
89
+ values.push(current);
90
+ current = '';
91
+ } else {
92
+ current += char;
93
+ }
94
+ }
95
+
96
+ // Add last value
97
+ values.push(current);
98
+
99
+ return values;
100
+ }
101
+
@@ -0,0 +1,4 @@
1
+ // Export functionality
2
+
3
+ export * from './csv';
4
+
@@ -0,0 +1,231 @@
1
+ // Filter functionality for spreadsheet sheets
2
+
3
+ import type { ColumnFilter, FilterCriteria, Sheet } from '../types';
4
+ import { getCellKey } from '../utils/cell-key';
5
+
6
+ /**
7
+ * Manages filtering operations for a spreadsheet sheet
8
+ */
9
+ export class FilterManager {
10
+ /**
11
+ * Apply filters to get visible row indices
12
+ * @param sheet The sheet to filter
13
+ * @param filters Map of column filters
14
+ * @param dataRange Optional range to filter, defaults to data rows (starting from row 1, assuming row 0 is header)
15
+ * @returns Set of visible row indices
16
+ */
17
+ static getFilteredRows(
18
+ sheet: Sheet,
19
+ filters: Map<number, ColumnFilter>,
20
+ dataRange?: { startRow: number; endRow: number }
21
+ ): Set<number> {
22
+ if (filters.size === 0) {
23
+ // No filters, all rows visible (including header row 0)
24
+ const visibleRows = new Set<number>();
25
+ const startRow = dataRange?.startRow ?? 0;
26
+ const endRow = dataRange?.endRow ?? sheet.rowCount - 1;
27
+ for (let row = startRow; row <= endRow; row++) {
28
+ visibleRows.add(row);
29
+ }
30
+ return visibleRows;
31
+ }
32
+
33
+ // Determine data range to filter (default to row 1+, assuming row 0 is header)
34
+ const startRow = dataRange?.startRow ?? Math.max(1, this.detectDataStartRow(sheet));
35
+ const endRow = dataRange?.endRow ?? this.detectDataEndRow(sheet);
36
+
37
+ const visibleRows = new Set<number>();
38
+
39
+ // Always include the header row (row 0) if we're starting from row 0 or earlier
40
+ if (dataRange?.startRow === undefined || dataRange.startRow <= 0) {
41
+ visibleRows.add(0);
42
+ }
43
+
44
+ // Check each data row against all filters
45
+ for (let row = startRow; row <= endRow; row++) {
46
+ let isVisible = true;
47
+
48
+ for (const [, filter] of filters) {
49
+ const cell = sheet.cells.get(getCellKey(row, filter.column));
50
+ const cellValue = cell?.value ?? null;
51
+
52
+ if (!this.matchesFilter(cellValue, filter.criteria)) {
53
+ isVisible = false;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (isVisible) {
59
+ visibleRows.add(row);
60
+ }
61
+ }
62
+
63
+ return visibleRows;
64
+ }
65
+
66
+ /**
67
+ * Check if a cell value matches a filter criteria
68
+ * @param cellValue The cell value to check
69
+ * @param criteria The filter criteria
70
+ * @returns true if the value matches the filter
71
+ */
72
+ private static matchesFilter(cellValue: unknown, criteria: FilterCriteria): boolean {
73
+ // Handle null/undefined values
74
+ if (cellValue === null || cellValue === undefined) {
75
+ // Null values only match if criteria allows empty values
76
+ return criteria.type === 'equals' && criteria.value === '';
77
+ }
78
+
79
+ // Convert cell value to string for text operations
80
+ const strValue = String(cellValue) as string;
81
+
82
+ switch (criteria.type) {
83
+ case 'equals':
84
+ return strValue === String(criteria.value);
85
+
86
+ case 'notEquals':
87
+ return strValue !== String(criteria.value);
88
+
89
+ case 'contains':
90
+ return strValue.toLowerCase().includes(criteria.value.toLowerCase());
91
+
92
+ case 'notContains':
93
+ return !strValue.toLowerCase().includes(criteria.value.toLowerCase());
94
+
95
+ case 'startsWith':
96
+ return strValue.toLowerCase().startsWith(criteria.value.toLowerCase());
97
+
98
+ case 'endsWith':
99
+ return strValue.toLowerCase().endsWith(criteria.value.toLowerCase());
100
+
101
+ case 'greaterThan': {
102
+ const numValue = Number(cellValue);
103
+ return !isNaN(numValue) && numValue > criteria.value;
104
+ }
105
+
106
+ case 'lessThan': {
107
+ const numValue = Number(cellValue);
108
+ return !isNaN(numValue) && numValue < criteria.value;
109
+ }
110
+
111
+ case 'greaterThanOrEqual': {
112
+ const numValue = Number(cellValue);
113
+ return !isNaN(numValue) && numValue >= criteria.value;
114
+ }
115
+
116
+ case 'lessThanOrEqual': {
117
+ const numValue = Number(cellValue);
118
+ return !isNaN(numValue) && numValue <= criteria.value;
119
+ }
120
+
121
+ case 'between': {
122
+ const numValue = Number(cellValue);
123
+ return !isNaN(numValue) && numValue >= criteria.min && numValue <= criteria.max;
124
+ }
125
+
126
+ case 'custom':
127
+ return criteria.values.has(cellValue as string | number);
128
+
129
+ default:
130
+ return true; // Unknown criteria type, show the row
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get unique values in a column for filter dropdowns
136
+ * @param sheet The sheet to analyze
137
+ * @param column Column index
138
+ * @param dataRange Optional range to analyze, defaults to data rows (starting from row 1, assuming row 0 is header)
139
+ * @returns Set of unique values in the column
140
+ */
141
+ static getUniqueColumnValues(
142
+ sheet: Sheet,
143
+ column: number,
144
+ dataRange?: { startRow: number; endRow: number }
145
+ ): Set<string | number> {
146
+ const uniqueValues = new Set<string | number>();
147
+
148
+ // Default to data rows only (skip header row 0), unless explicitly specified
149
+ const startRow = dataRange?.startRow ?? Math.max(1, this.detectDataStartRow(sheet));
150
+ const endRow = dataRange?.endRow ?? this.detectDataEndRow(sheet);
151
+
152
+ for (let row = startRow; row <= endRow; row++) {
153
+ const cell = sheet.cells.get(getCellKey(row, column));
154
+ if (cell?.value !== null && cell?.value !== undefined) {
155
+ uniqueValues.add(cell.value as string | number);
156
+ }
157
+ }
158
+
159
+ return uniqueValues;
160
+ }
161
+
162
+ /**
163
+ * Detect the first row that contains data (skip empty rows at the top)
164
+ */
165
+ private static detectDataStartRow(sheet: Sheet): number {
166
+ // Start from row 0, find first non-empty row
167
+ for (let row = 0; row < sheet.rowCount; row++) {
168
+ for (let col = 0; col < sheet.colCount; col++) {
169
+ const cell = sheet.cells.get(getCellKey(row, col));
170
+ if (cell && (cell.value !== null || cell.formula)) {
171
+ return row;
172
+ }
173
+ }
174
+ }
175
+ return 0;
176
+ }
177
+
178
+ /**
179
+ * Detect the last row that contains data (skip empty rows at the bottom)
180
+ */
181
+ private static detectDataEndRow(sheet: Sheet): number {
182
+ // Start from the bottom, find last non-empty row
183
+ for (let row = sheet.rowCount - 1; row >= 0; row--) {
184
+ for (let col = 0; col < sheet.colCount; col++) {
185
+ const cell = sheet.cells.get(getCellKey(row, col));
186
+ if (cell && (cell.value !== null || cell.formula)) {
187
+ return row;
188
+ }
189
+ }
190
+ }
191
+ return Math.max(0, sheet.rowCount - 1);
192
+ }
193
+
194
+ /**
195
+ * Check if a column has an active filter
196
+ * @param column Column index
197
+ * @param filters Current filters map
198
+ * @returns true if column has a filter
199
+ */
200
+ static hasFilter(column: number, filters: Map<number, ColumnFilter>): boolean {
201
+ return filters.has(column);
202
+ }
203
+
204
+ /**
205
+ * Get filter type based on column data
206
+ * @param sheet The sheet to analyze
207
+ * @param column Column index
208
+ * @returns 'text', 'number', or 'date' based on data analysis
209
+ */
210
+ static detectColumnType(sheet: Sheet, column: number): 'text' | 'number' | 'date' {
211
+ const uniqueValues = this.getUniqueColumnValues(sheet, column);
212
+ let hasNumbers = false;
213
+ let hasDates = false;
214
+
215
+ for (const value of uniqueValues) {
216
+ if (typeof value === 'number') {
217
+ hasNumbers = true;
218
+ } else if (typeof value === 'string') {
219
+ // Check if string looks like a date
220
+ const date = new Date(value);
221
+ if (!isNaN(date.getTime()) && value.match(/\d{1,2}[-/]\d{1,2}[-/]\d{4}|\d{4}[-/]\d{1,2}[-/]\d{1,2}/)) {
222
+ hasDates = true;
223
+ }
224
+ }
225
+ }
226
+
227
+ if (hasDates) return 'date';
228
+ if (hasNumbers) return 'number';
229
+ return 'text';
230
+ }
231
+ }
@@ -0,0 +1,271 @@
1
+ // Freeze Panes Utilities
2
+ // Handles calculation and management of frozen rows/columns
3
+
4
+ /**
5
+ * Configuration for freeze panes
6
+ */
7
+ export interface FreezeConfig {
8
+ frozenRows: number;
9
+ frozenCols: number;
10
+ }
11
+
12
+ /**
13
+ * Cached dimensions for frozen areas
14
+ */
15
+ export interface FreezeDimensions {
16
+ /** Total width of all frozen columns */
17
+ frozenWidth: number;
18
+ /** Total height of all frozen rows */
19
+ frozenHeight: number;
20
+ }
21
+
22
+ /**
23
+ * The 4 regions created by freeze panes
24
+ */
25
+ export type FreezeRegion =
26
+ | 'top-left' // Frozen rows AND cols - never scrolls
27
+ | 'top' // Frozen rows only - scrolls horizontally
28
+ | 'left' // Frozen cols only - scrolls vertically
29
+ | 'main'; // Regular scrollable area
30
+
31
+ /**
32
+ * Calculate the total dimensions of frozen areas
33
+ */
34
+ export function calculateFreezeDimensions(
35
+ frozenRows: number,
36
+ frozenCols: number,
37
+ rowHeights: Map<number, number>,
38
+ colWidths: Map<number, number>,
39
+ defaultRowHeight: number,
40
+ defaultColWidth: number,
41
+ hiddenRows?: Set<number>,
42
+ hiddenCols?: Set<number>
43
+ ): FreezeDimensions {
44
+ const hidRows = hiddenRows ?? new Set<number>();
45
+ const hidCols = hiddenCols ?? new Set<number>();
46
+
47
+ // Calculate frozen width (sum of frozen column widths, excluding hidden)
48
+ let frozenWidth = 0;
49
+ for (let c = 0; c < frozenCols; c++) {
50
+ if (!hidCols.has(c)) {
51
+ frozenWidth += colWidths.get(c) ?? defaultColWidth;
52
+ }
53
+ }
54
+
55
+ // Calculate frozen height (sum of frozen row heights, excluding hidden)
56
+ let frozenHeight = 0;
57
+ for (let r = 0; r < frozenRows; r++) {
58
+ if (!hidRows.has(r)) {
59
+ frozenHeight += rowHeights.get(r) ?? defaultRowHeight;
60
+ }
61
+ }
62
+
63
+ return { frozenWidth, frozenHeight };
64
+ }
65
+
66
+ /**
67
+ * Determine which freeze region a canvas point falls into
68
+ *
69
+ * @param x - Canvas x coordinate (relative to canvas origin, after headers)
70
+ * @param y - Canvas y coordinate (relative to canvas origin, after headers)
71
+ * @param frozenWidth - Total width of frozen columns
72
+ * @param frozenHeight - Total height of frozen rows
73
+ * @param headerWidth - Width of row headers
74
+ * @param headerHeight - Height of column headers
75
+ */
76
+ export function getRegionAtPoint(
77
+ x: number,
78
+ y: number,
79
+ frozenWidth: number,
80
+ frozenHeight: number,
81
+ headerWidth: number,
82
+ headerHeight: number
83
+ ): FreezeRegion {
84
+ const inFrozenCols = x <= headerWidth + frozenWidth;
85
+ const inFrozenRows = y <= headerHeight + frozenHeight;
86
+
87
+ if (inFrozenRows && inFrozenCols) {
88
+ return 'top-left';
89
+ } else if (inFrozenRows) {
90
+ return 'top';
91
+ } else if (inFrozenCols) {
92
+ return 'left';
93
+ } else {
94
+ return 'main';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Get the effective scroll offset for a given region
100
+ */
101
+ export function getScrollForRegion(
102
+ region: FreezeRegion,
103
+ scrollTop: number,
104
+ scrollLeft: number
105
+ ): { effectiveScrollTop: number; effectiveScrollLeft: number } {
106
+ switch (region) {
107
+ case 'top-left':
108
+ // Never scrolls
109
+ return { effectiveScrollTop: 0, effectiveScrollLeft: 0 };
110
+ case 'top':
111
+ // Only scrolls horizontally
112
+ return { effectiveScrollTop: 0, effectiveScrollLeft: scrollLeft };
113
+ case 'left':
114
+ // Only scrolls vertically
115
+ return { effectiveScrollTop: scrollTop, effectiveScrollLeft: 0 };
116
+ case 'main':
117
+ // Scrolls both ways
118
+ return { effectiveScrollTop: scrollTop, effectiveScrollLeft: scrollLeft };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Convert a canvas point to grid coordinates, accounting for freeze regions
124
+ *
125
+ * @param canvasX - X position on canvas (including headers)
126
+ * @param canvasY - Y position on canvas (including headers)
127
+ * @param scrollTop - Current vertical scroll position
128
+ * @param scrollLeft - Current horizontal scroll position
129
+ * @param frozenWidth - Total width of frozen columns
130
+ * @param frozenHeight - Total height of frozen rows
131
+ * @param headerWidth - Width of row headers
132
+ * @param headerHeight - Height of column headers
133
+ */
134
+ export function canvasToGridCoordinates(
135
+ canvasX: number,
136
+ canvasY: number,
137
+ scrollTop: number,
138
+ scrollLeft: number,
139
+ frozenWidth: number,
140
+ frozenHeight: number,
141
+ headerWidth: number,
142
+ headerHeight: number
143
+ ): { gridX: number; gridY: number } {
144
+ const region = getRegionAtPoint(
145
+ canvasX, canvasY,
146
+ frozenWidth, frozenHeight,
147
+ headerWidth, headerHeight
148
+ );
149
+
150
+ const { effectiveScrollTop, effectiveScrollLeft } = getScrollForRegion(
151
+ region, scrollTop, scrollLeft
152
+ );
153
+
154
+ // Convert to grid coordinates based on region
155
+ let gridX: number;
156
+ let gridY: number;
157
+
158
+ if (region === 'top-left' || region === 'left') {
159
+ // In frozen columns - no horizontal scroll adjustment
160
+ gridX = canvasX - headerWidth;
161
+ } else {
162
+ // In scrollable columns - apply scroll offset
163
+ gridX = canvasX - headerWidth + effectiveScrollLeft;
164
+ }
165
+
166
+ if (region === 'top-left' || region === 'top') {
167
+ // In frozen rows - no vertical scroll adjustment
168
+ gridY = canvasY - headerHeight;
169
+ } else {
170
+ // In scrollable rows - apply scroll offset
171
+ gridY = canvasY - headerHeight + effectiveScrollTop;
172
+ }
173
+
174
+ return { gridX, gridY };
175
+ }
176
+
177
+ /**
178
+ * Calculate clipping rectangle for a freeze region
179
+ *
180
+ * @returns Clip rect in canvas coordinates { x, y, width, height }
181
+ */
182
+ export function getClipRectForRegion(
183
+ region: FreezeRegion,
184
+ canvasWidth: number,
185
+ canvasHeight: number,
186
+ frozenWidth: number,
187
+ frozenHeight: number,
188
+ headerWidth: number,
189
+ headerHeight: number
190
+ ): { x: number; y: number; width: number; height: number } {
191
+ switch (region) {
192
+ case 'top-left':
193
+ return {
194
+ x: headerWidth,
195
+ y: headerHeight,
196
+ width: frozenWidth,
197
+ height: frozenHeight,
198
+ };
199
+ case 'top':
200
+ return {
201
+ x: headerWidth + frozenWidth,
202
+ y: headerHeight,
203
+ width: canvasWidth - headerWidth - frozenWidth,
204
+ height: frozenHeight,
205
+ };
206
+ case 'left':
207
+ return {
208
+ x: headerWidth,
209
+ y: headerHeight + frozenHeight,
210
+ width: frozenWidth,
211
+ height: canvasHeight - headerHeight - frozenHeight,
212
+ };
213
+ case 'main':
214
+ return {
215
+ x: headerWidth + frozenWidth,
216
+ y: headerHeight + frozenHeight,
217
+ width: canvasWidth - headerWidth - frozenWidth,
218
+ height: canvasHeight - headerHeight - frozenHeight,
219
+ };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Check if a row is in the frozen area
225
+ */
226
+ export function isRowFrozen(row: number, frozenRows: number): boolean {
227
+ return row < frozenRows;
228
+ }
229
+
230
+ /**
231
+ * Check if a column is in the frozen area
232
+ */
233
+ export function isColFrozen(col: number, frozenCols: number): boolean {
234
+ return col < frozenCols;
235
+ }
236
+
237
+ /**
238
+ * Check if a cell is fully in the frozen area (both row and col frozen)
239
+ */
240
+ export function isCellFullyFrozen(
241
+ row: number,
242
+ col: number,
243
+ frozenRows: number,
244
+ frozenCols: number
245
+ ): boolean {
246
+ return isRowFrozen(row, frozenRows) && isColFrozen(col, frozenCols);
247
+ }
248
+
249
+ /**
250
+ * Get the region a cell belongs to based on its row/col indices
251
+ */
252
+ export function getCellRegion(
253
+ row: number,
254
+ col: number,
255
+ frozenRows: number,
256
+ frozenCols: number
257
+ ): FreezeRegion {
258
+ const rowFrozen = isRowFrozen(row, frozenRows);
259
+ const colFrozen = isColFrozen(col, frozenCols);
260
+
261
+ if (rowFrozen && colFrozen) {
262
+ return 'top-left';
263
+ } else if (rowFrozen) {
264
+ return 'top';
265
+ } else if (colFrozen) {
266
+ return 'left';
267
+ } else {
268
+ return 'main';
269
+ }
270
+ }
271
+
@@ -0,0 +1,5 @@
1
+ // Features module - Advanced spreadsheet features
2
+ export * from './freeze';
3
+ export * from './sort';
4
+ export * from './filter';
5
+