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.
@@ -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