vue2-excel-grid 1.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.
- package/README.md +97 -0
- package/dist/vue2-excel-grid.esm.js +5883 -0
- package/dist/vue2-excel-grid.umd.js +6 -0
- package/package.json +74 -0
- package/src/components/ExcelGrid.vue +3552 -0
- package/src/index.js +9 -0
- package/src/utils/clipboard.js +141 -0
- package/src/utils/excelExport.js +144 -0
- package/src/utils/excelExportWorker.worker.js +140 -0
- package/src/utils/excelImport.js +118 -0
- package/src/utils/excelImportCore.js +247 -0
- package/src/utils/excelImportWorker.worker.js +104 -0
- package/src/utils/fillHandle.js +119 -0
- package/src/utils/headerTree.js +108 -0
- package/src/utils/i18n.js +101 -0
- package/src/utils/merge.js +77 -0
- package/src/utils/outline.js +109 -0
- package/src/utils/selection.js +76 -0
- package/src/utils/virtualScroll.js +148 -0
- package/src/utils/workerFactory.js +4 -0
- package/src/utils/workerFactory.webpack.js +7 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure header-detection / column-tree-building logic shared by the main-thread
|
|
3
|
+
* import path (excelImport.js) and the Web Worker import path
|
|
4
|
+
* (excelImportWorker.worker.js), so the two never drift apart.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function slugify(str) {
|
|
8
|
+
if (!str) return ''
|
|
9
|
+
return String(str)
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/\s+/g, '_')
|
|
12
|
+
.replace(/[^a-z0-9_]/g, '')
|
|
13
|
+
.replace(/^(\d)/, 'col_$1')
|
|
14
|
+
|| 'col'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function flatLeaves(cols) {
|
|
18
|
+
const result = []
|
|
19
|
+
for (const col of (cols || [])) {
|
|
20
|
+
if (col.children && col.children.length) {
|
|
21
|
+
result.push(...flatLeaves(col.children))
|
|
22
|
+
} else if (col.key) {
|
|
23
|
+
result.push(col)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Auto-detect header block and data start.
|
|
31
|
+
*
|
|
32
|
+
* @param {Array<Array<string>>} aoa
|
|
33
|
+
* @param {Array<{s:{r,c},e:{r,c}}>} merges — aoa-relative (already offset-corrected)
|
|
34
|
+
*
|
|
35
|
+
* 1. Look for a "column-number row" — a row where every non-empty cell is an
|
|
36
|
+
* integer and they form the exact sequence 1, 2, 3 … N (common in official
|
|
37
|
+
* government/accounting Excel registers).
|
|
38
|
+
* When found:
|
|
39
|
+
* dataStartRow = columnNumberRow + 1
|
|
40
|
+
* headerEndRow = first non-empty row strictly above columnNumberRow
|
|
41
|
+
* headerRow = walk up further while rows still have >1 non-empty cells
|
|
42
|
+
* 2. Fallback (no column-number row):
|
|
43
|
+
* Skip leading title rows (≤1 non-empty cell).
|
|
44
|
+
* First row with >1 non-empty cells = headerRow.
|
|
45
|
+
* headerEndRow extends downward to the tallest merge anchored at headerRow
|
|
46
|
+
* (handles multi-row merged headers, e.g. grouped column headers).
|
|
47
|
+
* In both cases, any fully blank rows right after the header block (a visual
|
|
48
|
+
* separator row) are skipped before settling on dataStartRow.
|
|
49
|
+
*/
|
|
50
|
+
export function detectImportBounds(aoa, merges) {
|
|
51
|
+
if (!aoa || aoa.length === 0) return { headerRow: 0, headerEndRow: 0, dataStartRow: 1 }
|
|
52
|
+
|
|
53
|
+
const nonEmpty = aoa.map(row => row.filter(c => c !== '').length)
|
|
54
|
+
merges = merges || []
|
|
55
|
+
|
|
56
|
+
let colNumRow = -1
|
|
57
|
+
for (let i = 0; i < Math.min(aoa.length, 20); i++) {
|
|
58
|
+
const vals = aoa[i].filter(c => c !== '')
|
|
59
|
+
if (vals.length < 2) continue
|
|
60
|
+
const nums = vals.map(v => {
|
|
61
|
+
const n = parseInt(String(v).trim(), 10)
|
|
62
|
+
return isNaN(n) ? null : n
|
|
63
|
+
})
|
|
64
|
+
if (nums.some(n => n === null)) continue
|
|
65
|
+
if (nums.every((n, idx) => n === idx + 1)) { colNumRow = i; break }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let headerRow, headerEndRow, dataStartRow
|
|
69
|
+
|
|
70
|
+
if (colNumRow >= 0) {
|
|
71
|
+
dataStartRow = colNumRow + 1
|
|
72
|
+
headerEndRow = colNumRow - 1
|
|
73
|
+
while (headerEndRow >= 0 && nonEmpty[headerEndRow] === 0) headerEndRow--
|
|
74
|
+
if (headerEndRow < 0) headerEndRow = 0
|
|
75
|
+
headerRow = headerEndRow
|
|
76
|
+
while (headerRow > 0 && nonEmpty[headerRow - 1] > 1) headerRow--
|
|
77
|
+
} else {
|
|
78
|
+
headerRow = 0
|
|
79
|
+
for (let i = 0; i < Math.min(aoa.length, 20); i++) {
|
|
80
|
+
if (nonEmpty[i] > 1) { headerRow = i; break }
|
|
81
|
+
}
|
|
82
|
+
let maxSpan = 1
|
|
83
|
+
for (const m of merges) {
|
|
84
|
+
if (m.s.r === headerRow && m.e.r > m.s.r) {
|
|
85
|
+
maxSpan = Math.max(maxSpan, m.e.r - m.s.r + 1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
headerEndRow = headerRow + maxSpan - 1
|
|
89
|
+
dataStartRow = headerEndRow + 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
while (dataStartRow < aoa.length && nonEmpty[dataStartRow] === 0) dataStartRow++
|
|
93
|
+
|
|
94
|
+
return { headerRow, headerEndRow, dataStartRow }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check whether there is any non-empty cell in aoa[rowStart..rowEnd][colStart..colEnd].
|
|
99
|
+
*/
|
|
100
|
+
export function hasNonEmptyInRange(aoa, rowStart, rowEnd, colStart, colEnd) {
|
|
101
|
+
for (let r = rowStart; r <= rowEnd; r++) {
|
|
102
|
+
for (let c = colStart; c <= colEnd; c++) {
|
|
103
|
+
if (((aoa[r] || [])[c] || '').toString().trim() !== '') return true
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function cellText(aoa, r, c) {
|
|
110
|
+
return String(((aoa[r] || [])[c] || '')).trim().replace(/\n/g, ' ')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build a nested column tree from the multi-row header block, plus a flat
|
|
115
|
+
* colMap list used for mapping data rows.
|
|
116
|
+
*
|
|
117
|
+
* Rules:
|
|
118
|
+
* - A merge that spans multiple COLUMNS (horizontal) → always a PARENT; its
|
|
119
|
+
* children are the header items found in the rows below the merge.
|
|
120
|
+
* - A merge that spans a single column all the way to headerEndRow → LEAF.
|
|
121
|
+
* - A plain cell (no merge) on the last header row → LEAF.
|
|
122
|
+
* - A plain cell (no merge) above the last header row with non-empty content
|
|
123
|
+
* below it → PARENT; otherwise LEAF.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{ columns: Column[], colMap: Array<{key, colIndex}> }}
|
|
126
|
+
*/
|
|
127
|
+
export function buildNestedColumns(aoa, headerRow, headerEndRow, sheetMerges, existingLeaves, usedSlugs) {
|
|
128
|
+
// Determine the last meaningful column index from header content + merges
|
|
129
|
+
let maxCol = -1
|
|
130
|
+
for (let r = headerRow; r <= headerEndRow; r++) {
|
|
131
|
+
for (let c = (aoa[r] || []).length - 1; c >= 0; c--) {
|
|
132
|
+
if (((aoa[r] || [])[c] || '') !== '') { maxCol = Math.max(maxCol, c); break }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const headerMerges = (sheetMerges || []).filter(
|
|
136
|
+
m => m.s.r >= headerRow && m.s.r <= headerEndRow
|
|
137
|
+
)
|
|
138
|
+
for (const m of headerMerges) maxCol = Math.max(maxCol, m.e.c)
|
|
139
|
+
if (maxCol < 0) return { columns: [], colMap: [] }
|
|
140
|
+
|
|
141
|
+
const colMap = []
|
|
142
|
+
|
|
143
|
+
function makeLeaf(title, colIndex) {
|
|
144
|
+
const matched = existingLeaves.find(
|
|
145
|
+
l => l.title && l.title.toLowerCase() === title.toLowerCase()
|
|
146
|
+
)
|
|
147
|
+
if (matched) {
|
|
148
|
+
return { key: matched.key, title: matched.title, width: matched.width || 100 }
|
|
149
|
+
}
|
|
150
|
+
// Build a slug that strips non-ASCII chars AND collapses leftover underscores.
|
|
151
|
+
// Fall back to a positional key so Cyrillic-only titles never get just '_'.
|
|
152
|
+
const rawSlug = String(title || '')
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.replace(/\s+/g, '_')
|
|
155
|
+
.replace(/[^a-z0-9_]/g, '')
|
|
156
|
+
.replace(/^_+|_+$/g, '')
|
|
157
|
+
.replace(/_+/g, '_')
|
|
158
|
+
.replace(/^(\d)/, 'col_$1')
|
|
159
|
+
let slug = rawSlug || `col_${colIndex}`
|
|
160
|
+
let finalSlug = slug
|
|
161
|
+
let n = 1
|
|
162
|
+
while (usedSlugs.has(finalSlug) || existingLeaves.some(l => l.key === finalSlug)) {
|
|
163
|
+
finalSlug = `${slug}_${n++}`
|
|
164
|
+
}
|
|
165
|
+
usedSlugs.add(finalSlug)
|
|
166
|
+
return { key: finalSlug, title: title || finalSlug, width: 100 }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function processRange(rowStart, rowEnd, colStart, colEnd) {
|
|
170
|
+
if (rowStart > rowEnd || colStart > colEnd) return []
|
|
171
|
+
const result = []
|
|
172
|
+
let c = colStart
|
|
173
|
+
|
|
174
|
+
while (c <= colEnd) {
|
|
175
|
+
// Skip cells covered by a horizontal merge that started to the left
|
|
176
|
+
const covered = headerMerges.some(m =>
|
|
177
|
+
m.s.r <= rowStart && m.e.r >= rowStart &&
|
|
178
|
+
m.s.c < c && m.e.c >= c
|
|
179
|
+
)
|
|
180
|
+
if (covered) { c++; continue }
|
|
181
|
+
|
|
182
|
+
const startMerge = headerMerges.find(m => m.s.r === rowStart && m.s.c === c)
|
|
183
|
+
const title = startMerge
|
|
184
|
+
? cellText(aoa, startMerge.s.r, startMerge.s.c)
|
|
185
|
+
: cellText(aoa, rowStart, c)
|
|
186
|
+
|
|
187
|
+
if (startMerge && startMerge.e.c > c) {
|
|
188
|
+
// ── Horizontal span ─────────────────────────────────────────────────
|
|
189
|
+
const spanColEnd = Math.min(startMerge.e.c, colEnd)
|
|
190
|
+
const childRowStart = startMerge.e.r + 1
|
|
191
|
+
|
|
192
|
+
if (childRowStart <= rowEnd && hasNonEmptyInRange(aoa, childRowStart, rowEnd, c, spanColEnd)) {
|
|
193
|
+
const children = processRange(childRowStart, rowEnd, c, spanColEnd)
|
|
194
|
+
result.push({ title, children })
|
|
195
|
+
} else {
|
|
196
|
+
// No sub-headers below → individual leaf per physical column
|
|
197
|
+
for (let ci = c; ci <= spanColEnd; ci++) {
|
|
198
|
+
const leaf = makeLeaf(title, ci)
|
|
199
|
+
result.push(leaf)
|
|
200
|
+
colMap.push({ key: leaf.key, colIndex: ci })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
c = spanColEnd + 1
|
|
204
|
+
} else if (startMerge && startMerge.e.r >= rowEnd) {
|
|
205
|
+
// ── Single-column merge spanning to bottom row ─── LEAF ────────────
|
|
206
|
+
const leaf = makeLeaf(title, c)
|
|
207
|
+
result.push(leaf)
|
|
208
|
+
colMap.push({ key: leaf.key, colIndex: c })
|
|
209
|
+
c++
|
|
210
|
+
} else if (startMerge) {
|
|
211
|
+
// ── Single-column merge that ends before bottom row ─────────────────
|
|
212
|
+
const childRowStart = startMerge.e.r + 1
|
|
213
|
+
if (childRowStart <= rowEnd && hasNonEmptyInRange(aoa, childRowStart, rowEnd, c, c)) {
|
|
214
|
+
const children = processRange(childRowStart, rowEnd, c, c)
|
|
215
|
+
result.push({ title, children })
|
|
216
|
+
} else {
|
|
217
|
+
const leaf = makeLeaf(title, c)
|
|
218
|
+
result.push(leaf)
|
|
219
|
+
colMap.push({ key: leaf.key, colIndex: c })
|
|
220
|
+
}
|
|
221
|
+
c++
|
|
222
|
+
} else if (rowStart === rowEnd) {
|
|
223
|
+
// ── Last header row, plain cell ─── LEAF ───────────────────────────
|
|
224
|
+
const leaf = makeLeaf(title, c)
|
|
225
|
+
result.push(leaf)
|
|
226
|
+
colMap.push({ key: leaf.key, colIndex: c })
|
|
227
|
+
c++
|
|
228
|
+
} else {
|
|
229
|
+
// ── Upper row, plain cell — check for non-empty content below ───────
|
|
230
|
+
if (hasNonEmptyInRange(aoa, rowStart + 1, rowEnd, c, c)) {
|
|
231
|
+
const children = processRange(rowStart + 1, rowEnd, c, c)
|
|
232
|
+
result.push({ title, children })
|
|
233
|
+
} else {
|
|
234
|
+
const leaf = makeLeaf(title, c)
|
|
235
|
+
result.push(leaf)
|
|
236
|
+
colMap.push({ key: leaf.key, colIndex: c })
|
|
237
|
+
}
|
|
238
|
+
c++
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return result
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const columns = processRange(headerRow, headerEndRow, 0, maxCol)
|
|
246
|
+
return { columns, colMap }
|
|
247
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Worker — Excel/CSV import.
|
|
3
|
+
*
|
|
4
|
+
* Protocol:
|
|
5
|
+
* IN postMessage({ buffer: ArrayBuffer, existingColumns: [], options: {} })
|
|
6
|
+
* OUT postMessage({ type: 'progress', percent, message })
|
|
7
|
+
* OUT postMessage({ type: 'result', data, columns, merges })
|
|
8
|
+
* OUT postMessage({ type: 'error', message })
|
|
9
|
+
*/
|
|
10
|
+
import * as XLSX from 'xlsx'
|
|
11
|
+
import { flatLeaves, detectImportBounds, buildNestedColumns } from './excelImportCore.js'
|
|
12
|
+
|
|
13
|
+
const CHUNK_SIZE = 5000
|
|
14
|
+
|
|
15
|
+
self.onmessage = function (e) {
|
|
16
|
+
const { buffer, existingColumns, options } = e.data
|
|
17
|
+
const report = (percent, message) =>
|
|
18
|
+
self.postMessage({ type: 'progress', percent, message })
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
report(10, 'Excel tahlil qilinmoqda...')
|
|
22
|
+
|
|
23
|
+
let workbook
|
|
24
|
+
try {
|
|
25
|
+
workbook = XLSX.read(new Uint8Array(buffer), { type: 'array', cellDates: true })
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new Error("Excel fayl o'qib bo'lmadi: " + err.message)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { sheetName } = options || {}
|
|
31
|
+
let sheet
|
|
32
|
+
if (sheetName) {
|
|
33
|
+
sheet = workbook.Sheets[sheetName]
|
|
34
|
+
if (!sheet) throw new Error(`Sheet "${sheetName}" topilmadi`)
|
|
35
|
+
} else {
|
|
36
|
+
const first = workbook.SheetNames[0]
|
|
37
|
+
if (!first) throw new Error("Workbook bo'sh")
|
|
38
|
+
sheet = workbook.Sheets[first]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
report(30, "Ma'lumotlar tayyorlanmoqda...")
|
|
42
|
+
|
|
43
|
+
const aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' })
|
|
44
|
+
if (!aoa || aoa.length === 0) throw new Error("Sheet bo'sh")
|
|
45
|
+
|
|
46
|
+
// `sheet['!merges']` row/col indices are sheet-absolute, but `aoa` is relative
|
|
47
|
+
// to the sheet's used range (e.g. starts at A2 instead of A1). Normalize merges
|
|
48
|
+
// to aoa-relative coordinates so they line up with header/data row indices below.
|
|
49
|
+
const usedRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1:A1')
|
|
50
|
+
const rowOffset = usedRange.s.r
|
|
51
|
+
const colOffset = usedRange.s.c
|
|
52
|
+
const merges = (sheet['!merges'] || []).map(m => ({
|
|
53
|
+
s: { r: m.s.r - rowOffset, c: m.s.c - colOffset },
|
|
54
|
+
e: { r: m.e.r - rowOffset, c: m.e.c - colOffset }
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
const detected = detectImportBounds(aoa, merges)
|
|
58
|
+
const headerRowIdx = (options && options.headerRow != null) ? options.headerRow : detected.headerRow
|
|
59
|
+
const headerEndRowIdx = (options && options.headerEndRow != null) ? options.headerEndRow : detected.headerEndRow
|
|
60
|
+
const dataStartRowIdx = (options && options.dataStartRow != null) ? options.dataStartRow : detected.dataStartRow
|
|
61
|
+
|
|
62
|
+
const existingLeaves = flatLeaves(existingColumns)
|
|
63
|
+
const usedSlugs = new Set()
|
|
64
|
+
|
|
65
|
+
const { columns: newColumns, colMap } = buildNestedColumns(
|
|
66
|
+
aoa, headerRowIdx, headerEndRowIdx, merges,
|
|
67
|
+
existingLeaves, usedSlugs
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const totalDataRows = Math.max(aoa.length - dataStartRowIdx, 0)
|
|
71
|
+
report(50, `Qatorlar qayta ishlanmoqda (0 / ${totalDataRows})...`)
|
|
72
|
+
|
|
73
|
+
const data = []
|
|
74
|
+
for (let i = dataStartRowIdx; i < aoa.length; i += CHUNK_SIZE) {
|
|
75
|
+
const end = Math.min(i + CHUNK_SIZE, aoa.length)
|
|
76
|
+
for (let r = i; r < end; r++) {
|
|
77
|
+
const rowArr = aoa[r]
|
|
78
|
+
const obj = {}
|
|
79
|
+
for (const { key, colIndex } of colMap) {
|
|
80
|
+
obj[key] = rowArr[colIndex] !== undefined ? rowArr[colIndex] : ''
|
|
81
|
+
}
|
|
82
|
+
data.push(obj)
|
|
83
|
+
}
|
|
84
|
+
const processed = Math.min(end - dataStartRowIdx, totalDataRows)
|
|
85
|
+
const pct = 50 + Math.round(45 * processed / Math.max(totalDataRows, 1))
|
|
86
|
+
report(pct, `Qatorlar: ${processed} / ${totalDataRows}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const dataMerges = merges
|
|
90
|
+
.filter(m => m.s.r >= dataStartRowIdx)
|
|
91
|
+
.map(m => ({
|
|
92
|
+
startRow: m.s.r - dataStartRowIdx,
|
|
93
|
+
startCol: m.s.c,
|
|
94
|
+
endRow: m.e.r - dataStartRowIdx,
|
|
95
|
+
endCol: m.e.c
|
|
96
|
+
}))
|
|
97
|
+
.filter(m => m.startRow >= 0 && m.startCol >= 0)
|
|
98
|
+
|
|
99
|
+
report(100, 'Tayyor!')
|
|
100
|
+
self.postMessage({ type: 'result', data, columns: newColumns, merges: dataMerges })
|
|
101
|
+
} catch (err) {
|
|
102
|
+
self.postMessage({ type: 'error', message: err.message })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fill handle: pattern detection and value generation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const WEEKDAYS_UZ = ['Dushanba', 'Seshanba', 'Chorshanba', 'Payshanba', 'Juma', 'Shanba', 'Yakshanba']
|
|
6
|
+
const WEEKDAYS_EN = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
|
7
|
+
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
8
|
+
'July', 'August', 'September', 'October', 'November', 'December']
|
|
9
|
+
|
|
10
|
+
// ISO date pattern: YYYY-MM-DD or YYYY/MM/DD
|
|
11
|
+
const ISO_DATE_RE = /^\d{4}[-/]\d{2}[-/]\d{2}$/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect if a value looks like an ISO date string (not just a number starting with year)
|
|
15
|
+
*/
|
|
16
|
+
function isDateLike(v) {
|
|
17
|
+
return typeof v === 'string' && ISO_DATE_RE.test(v.trim())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a value is purely numeric (strict, no date-like strings)
|
|
22
|
+
*/
|
|
23
|
+
function isPureNumber(v) {
|
|
24
|
+
if (typeof v === 'boolean') return false
|
|
25
|
+
const s = String(v).trim()
|
|
26
|
+
if (s === '') return false
|
|
27
|
+
if (isDateLike(s)) return false
|
|
28
|
+
return !isNaN(Number(s))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect pattern type from an array of values.
|
|
33
|
+
* Returns: { type: 'number'|'date'|'weekday_uz'|'weekday_en'|'month'|'copy', step, base }
|
|
34
|
+
*/
|
|
35
|
+
export function detectPattern(values) {
|
|
36
|
+
if (values.length === 0) return { type: 'copy', step: 0, base: null }
|
|
37
|
+
if (values.length === 1) return { type: 'copy', step: 0, base: values[0] }
|
|
38
|
+
|
|
39
|
+
// Weekdays (Uzbek)?
|
|
40
|
+
const uzIdxs = values.map(v => WEEKDAYS_UZ.indexOf(v))
|
|
41
|
+
if (uzIdxs.every(i => i >= 0)) {
|
|
42
|
+
const step = values.length >= 2 ? ((uzIdxs[1] - uzIdxs[0] + 7) % 7) || 1 : 1
|
|
43
|
+
return { type: 'weekday_uz', step, base: uzIdxs[0] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Weekdays (English)?
|
|
47
|
+
const enIdxs = values.map(v => WEEKDAYS_EN.indexOf(v))
|
|
48
|
+
if (enIdxs.every(i => i >= 0)) {
|
|
49
|
+
const step = values.length >= 2 ? ((enIdxs[1] - enIdxs[0] + 7) % 7) || 1 : 1
|
|
50
|
+
return { type: 'weekday_en', step, base: enIdxs[0] }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Months (English)?
|
|
54
|
+
const mIdxs = values.map(v => MONTHS_EN.indexOf(v))
|
|
55
|
+
if (mIdxs.every(i => i >= 0)) {
|
|
56
|
+
const step = values.length >= 2 ? ((mIdxs[1] - mIdxs[0] + 12) % 12) || 1 : 1
|
|
57
|
+
return { type: 'month', step, base: mIdxs[0] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ISO Date strings? (check before numbers to avoid '2024-01-01' being parsed as 2024)
|
|
61
|
+
if (values.every(isDateLike)) {
|
|
62
|
+
const times = values.map(v => new Date(v).getTime())
|
|
63
|
+
if (times.every(t => !isNaN(t))) {
|
|
64
|
+
const steps = times.slice(1).map((t, i) => t - times[i])
|
|
65
|
+
const isLinear = steps.every(s => Math.abs(s - steps[0]) < 86400000) // within 1 day tolerance
|
|
66
|
+
if (isLinear) return { type: 'date', step: steps[0], base: times[0] }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// All pure numbers?
|
|
71
|
+
if (values.every(isPureNumber)) {
|
|
72
|
+
const nums = values.map(v => Number(v))
|
|
73
|
+
const steps = nums.slice(1).map((n, i) => n - nums[i])
|
|
74
|
+
const isLinear = steps.every(s => Math.abs(s - steps[0]) < 1e-10)
|
|
75
|
+
if (isLinear) return { type: 'number', step: steps[0], base: nums[0] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { type: 'copy', step: 0, base: values[0] }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate fill values for `count` additional cells beyond the source values.
|
|
83
|
+
*
|
|
84
|
+
* @param {Array} sourceValues - the source cell values
|
|
85
|
+
* @param {number} count - how many new values to generate
|
|
86
|
+
* @returns {Array}
|
|
87
|
+
*/
|
|
88
|
+
export function generateFillValues(sourceValues, count) {
|
|
89
|
+
const pattern = detectPattern(sourceValues)
|
|
90
|
+
const n = sourceValues.length
|
|
91
|
+
const result = []
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < count; i++) {
|
|
94
|
+
const pos = n + i // absolute position from start
|
|
95
|
+
switch (pattern.type) {
|
|
96
|
+
case 'number':
|
|
97
|
+
result.push(pattern.base + pattern.step * pos)
|
|
98
|
+
break
|
|
99
|
+
case 'weekday_uz':
|
|
100
|
+
result.push(WEEKDAYS_UZ[(pattern.base + pattern.step * pos) % 7])
|
|
101
|
+
break
|
|
102
|
+
case 'weekday_en':
|
|
103
|
+
result.push(WEEKDAYS_EN[(pattern.base + pattern.step * pos) % 7])
|
|
104
|
+
break
|
|
105
|
+
case 'month':
|
|
106
|
+
result.push(MONTHS_EN[(pattern.base + pattern.step * pos) % 12])
|
|
107
|
+
break
|
|
108
|
+
case 'date': {
|
|
109
|
+
const d = new Date(pattern.base + pattern.step * pos)
|
|
110
|
+
result.push(d.toISOString().split('T')[0])
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
// copy: cycle through source values
|
|
115
|
+
result.push(sourceValues[pos % n])
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result
|
|
119
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for building and traversing the nested column header tree
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Flatten the column tree into a list of leaf columns (those with a `key`).
|
|
7
|
+
* Returns an array of leaf column definitions, each augmented with their
|
|
8
|
+
* flat index (`_flatIndex`).
|
|
9
|
+
*
|
|
10
|
+
* @param {Array} columns - raw column config (may contain `children`)
|
|
11
|
+
* @returns {Array} flat leaf columns
|
|
12
|
+
*/
|
|
13
|
+
export function flattenColumns(columns) {
|
|
14
|
+
const leaves = []
|
|
15
|
+
function walk(cols) {
|
|
16
|
+
for (const col of cols) {
|
|
17
|
+
if (col.children && col.children.length) {
|
|
18
|
+
walk(col.children)
|
|
19
|
+
} else {
|
|
20
|
+
leaves.push({ ...col, _flatIndex: leaves.length })
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
walk(columns)
|
|
25
|
+
return leaves
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the maximum depth of the column tree (for multi-row headers).
|
|
30
|
+
* A flat column has depth 1.
|
|
31
|
+
*/
|
|
32
|
+
export function columnTreeDepth(columns) {
|
|
33
|
+
function depth(cols) {
|
|
34
|
+
let max = 0
|
|
35
|
+
for (const col of cols) {
|
|
36
|
+
if (col.children && col.children.length) {
|
|
37
|
+
max = Math.max(max, 1 + depth(col.children))
|
|
38
|
+
} else {
|
|
39
|
+
max = Math.max(max, 1)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return max
|
|
43
|
+
}
|
|
44
|
+
return depth(columns)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build header rows suitable for rendering.
|
|
49
|
+
* Each header row is an array of header cell descriptors:
|
|
50
|
+
* { column, colspan, rowspan, isGroup }
|
|
51
|
+
*
|
|
52
|
+
* @param {Array} columns
|
|
53
|
+
* @param {number} totalDepth - from columnTreeDepth()
|
|
54
|
+
* @returns {Array<Array>} rows of header cell objects
|
|
55
|
+
*/
|
|
56
|
+
export function buildHeaderRows(columns, totalDepth) {
|
|
57
|
+
const rows = Array.from({ length: totalDepth }, () => [])
|
|
58
|
+
let flatIndex = 0
|
|
59
|
+
|
|
60
|
+
function walk(cols, rowIndex) {
|
|
61
|
+
for (const col of cols) {
|
|
62
|
+
if (col.children && col.children.length) {
|
|
63
|
+
const leafCount = countLeaves(col)
|
|
64
|
+
const firstLeafFlatIndex = flatIndex // capture before descending
|
|
65
|
+
rows[rowIndex].push({
|
|
66
|
+
column: col,
|
|
67
|
+
colspan: leafCount,
|
|
68
|
+
rowspan: 1,
|
|
69
|
+
isGroup: true,
|
|
70
|
+
_firstLeafFlatIndex: firstLeafFlatIndex
|
|
71
|
+
})
|
|
72
|
+
walk(col.children, rowIndex + 1)
|
|
73
|
+
} else {
|
|
74
|
+
const rowspan = totalDepth - rowIndex
|
|
75
|
+
rows[rowIndex].push({
|
|
76
|
+
column: { ...col, _flatIndex: flatIndex },
|
|
77
|
+
colspan: 1,
|
|
78
|
+
rowspan,
|
|
79
|
+
isGroup: false,
|
|
80
|
+
_firstLeafFlatIndex: flatIndex
|
|
81
|
+
})
|
|
82
|
+
flatIndex++
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
walk(columns, 0)
|
|
88
|
+
return rows
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Count the number of leaf columns under a column node
|
|
93
|
+
*/
|
|
94
|
+
export function countLeaves(col) {
|
|
95
|
+
if (!col.children || col.children.length === 0) return 1
|
|
96
|
+
return col.children.reduce((sum, c) => sum + countLeaves(c), 0)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the total width of a column node (sum of all leaf widths)
|
|
101
|
+
*/
|
|
102
|
+
export function columnTotalWidth(col, leafWidths) {
|
|
103
|
+
if (!col.children || col.children.length === 0) {
|
|
104
|
+
const idx = col._flatIndex
|
|
105
|
+
return leafWidths[idx] !== undefined ? leafWidths[idx] : (col.width || 100)
|
|
106
|
+
}
|
|
107
|
+
return col.children.reduce((sum, c) => sum + columnTotalWidth(c, leafWidths), 0)
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context menu translations for ExcelGrid.
|
|
3
|
+
* Supported locales: 'en' | 'ru' | 'uz' | 'uz-Cyrl'
|
|
4
|
+
*/
|
|
5
|
+
const messages = {
|
|
6
|
+
en: {
|
|
7
|
+
copy: 'Copy',
|
|
8
|
+
cut: 'Cut',
|
|
9
|
+
paste: 'Paste',
|
|
10
|
+
pasteSpecial: 'Paste Special',
|
|
11
|
+
pasteAll: 'All (Value + Format)',
|
|
12
|
+
pasteValues: 'Values Only',
|
|
13
|
+
pasteFormats: 'Formats Only',
|
|
14
|
+
transpose: 'Transpose',
|
|
15
|
+
mergeCells: 'Merge Cells',
|
|
16
|
+
unmergeCells: 'Unmerge Cells',
|
|
17
|
+
hideRows: 'Hide Row(s)',
|
|
18
|
+
hideColumns: 'Hide Column(s)',
|
|
19
|
+
unhideRows: 'Unhide All Rows',
|
|
20
|
+
unhideColumns: 'Unhide All Columns',
|
|
21
|
+
pinLeft: 'Pin Left',
|
|
22
|
+
pinRight: 'Pin Right',
|
|
23
|
+
unpin: 'Unpin',
|
|
24
|
+
invalidValue: 'Invalid value'
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
ru: {
|
|
28
|
+
copy: 'Копировать',
|
|
29
|
+
cut: 'Вырезать',
|
|
30
|
+
paste: 'Вставить',
|
|
31
|
+
pasteSpecial: 'Специальная вставка',
|
|
32
|
+
pasteAll: 'Всё (значение + формат)',
|
|
33
|
+
pasteValues: 'Только значения',
|
|
34
|
+
pasteFormats: 'Только форматы',
|
|
35
|
+
transpose: 'Транспонировать',
|
|
36
|
+
mergeCells: 'Объединить ячейки',
|
|
37
|
+
unmergeCells: 'Разъединить ячейки',
|
|
38
|
+
hideRows: 'Скрыть строку(и)',
|
|
39
|
+
hideColumns: 'Скрыть столбец(цы)',
|
|
40
|
+
unhideRows: 'Показать все строки',
|
|
41
|
+
unhideColumns: 'Показать все столбцы',
|
|
42
|
+
pinLeft: 'Закрепить слева',
|
|
43
|
+
pinRight: 'Закрепить справа',
|
|
44
|
+
unpin: 'Открепить',
|
|
45
|
+
invalidValue: 'Недопустимое значение'
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
uz: {
|
|
49
|
+
copy: 'Nusxalash',
|
|
50
|
+
cut: 'Kesib olish',
|
|
51
|
+
paste: 'Joylashtirish',
|
|
52
|
+
pasteSpecial: 'Maxsus joylashtirish',
|
|
53
|
+
pasteAll: 'Hammasi (qiymat + format)',
|
|
54
|
+
pasteValues: 'Faqat qiymatlar',
|
|
55
|
+
pasteFormats: 'Faqat formatlar',
|
|
56
|
+
transpose: 'Transpozitsiya',
|
|
57
|
+
mergeCells: 'Katakchalarni birlashtirish',
|
|
58
|
+
unmergeCells: 'Katakchalarni ajratish',
|
|
59
|
+
hideRows: 'Qatorlarni yashirish',
|
|
60
|
+
hideColumns: 'Ustunlarni yashirish',
|
|
61
|
+
unhideRows: 'Barcha qatorlarni ko\'rsatish',
|
|
62
|
+
unhideColumns: 'Barcha ustunlarni ko\'rsatish',
|
|
63
|
+
pinLeft: 'Chapga pin qilish',
|
|
64
|
+
pinRight: 'O\'ngga pin qilish',
|
|
65
|
+
unpin: 'Pindan olish',
|
|
66
|
+
invalidValue: 'Noto\'g\'ri qiymat'
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
'uz-Cyrl': {
|
|
70
|
+
copy: 'Нусхалаш',
|
|
71
|
+
cut: 'Кесиб олиш',
|
|
72
|
+
paste: 'Жойлаштириш',
|
|
73
|
+
pasteSpecial: 'Махсус жойлаштириш',
|
|
74
|
+
pasteAll: 'Ҳаммаси (қиймат + формат)',
|
|
75
|
+
pasteValues: 'Фақат қийматлар',
|
|
76
|
+
pasteFormats: 'Фақат форматлар',
|
|
77
|
+
transpose: 'Транспозиция',
|
|
78
|
+
mergeCells: 'Катакчаларни бирлаштириш',
|
|
79
|
+
unmergeCells: 'Катакчаларни ажратиш',
|
|
80
|
+
hideRows: 'Қаторларни яшириш',
|
|
81
|
+
hideColumns: 'Устунларни яшириш',
|
|
82
|
+
unhideRows: 'Барча қаторларни кўрсатиш',
|
|
83
|
+
unhideColumns: 'Барча устунларни кўрсатиш',
|
|
84
|
+
pinLeft: 'Чапга пин қилиш',
|
|
85
|
+
pinRight: 'Ўнгга пин қилиш',
|
|
86
|
+
unpin: 'Пиндан олиш',
|
|
87
|
+
invalidValue: 'Нотўғри қиймат'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the translation object for the given locale.
|
|
93
|
+
* Falls back to 'en' if the locale is unknown.
|
|
94
|
+
* @param {string} locale
|
|
95
|
+
* @returns {Object}
|
|
96
|
+
*/
|
|
97
|
+
export function getMessages(locale) {
|
|
98
|
+
return messages[locale] || messages.en
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default messages
|