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,3552 @@
1
+ <template>
2
+ <div
3
+ class="eg-wrapper"
4
+ :style="wrapperStyle"
5
+ tabindex="0"
6
+ @keydown="onKeyDown"
7
+ @keypress="onKeyPress"
8
+ ref="wrapper"
9
+ >
10
+ <!-- Context menu -->
11
+ <div
12
+ v-if="contextMenu.visible"
13
+ class="eg-context-menu"
14
+ :style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
15
+ @click.stop
16
+ >
17
+ <div class="eg-context-item" @click="ctxCopy">{{ t.copy }}</div>
18
+ <div class="eg-context-item" @click="ctxCut">{{ t.cut }}</div>
19
+ <div class="eg-context-item" @click="ctxPaste">{{ t.paste }}</div>
20
+ <div class="eg-context-separator"></div>
21
+ <div class="eg-context-item eg-context-submenu-parent">
22
+ {{ t.pasteSpecial }}
23
+ <div class="eg-context-submenu">
24
+ <div class="eg-context-item" @click="ctxPasteSpecial('all')">{{ t.pasteAll }}</div>
25
+ <div class="eg-context-item" @click="ctxPasteSpecial('values')">{{ t.pasteValues }}</div>
26
+ <div class="eg-context-item" @click="ctxPasteSpecial('formats')">{{ t.pasteFormats }}</div>
27
+ <div class="eg-context-item" @click="ctxPasteSpecial('all', true)">{{ t.transpose }}</div>
28
+ </div>
29
+ </div>
30
+ <div class="eg-context-separator"></div>
31
+ <div class="eg-context-item" @click="ctxMerge">{{ t.mergeCells }}</div>
32
+ <div class="eg-context-item" @click="ctxUnmerge">{{ t.unmergeCells }}</div>
33
+ <div class="eg-context-separator"></div>
34
+ <div class="eg-context-item" @click="ctxHideRow">{{ t.hideRows }}</div>
35
+ <div class="eg-context-item" @click="ctxHideColumn">{{ t.hideColumns }}</div>
36
+ <div class="eg-context-item" @click="ctxUnhideRows">{{ t.unhideRows }}</div>
37
+ <div class="eg-context-item" @click="ctxUnhideColumns">{{ t.unhideColumns }}</div>
38
+ <template v-if="contextMenu.row < 0 && contextMenu.col >= 0">
39
+ <div class="eg-context-separator"></div>
40
+ <div class="eg-context-item" @click="ctxPinLeft">{{ t.pinLeft }}</div>
41
+ <div class="eg-context-item" @click="ctxPinRight">{{ t.pinRight }}</div>
42
+ <div class="eg-context-item" v-if="isColPinned(contextMenu.col)" @click="ctxUnpin">{{ t.unpin }}</div>
43
+ </template>
44
+ </div>
45
+
46
+ <!-- Scroll container -->
47
+ <div class="eg-container" ref="container" @scroll="onScroll" @click="hideContextMenu">
48
+
49
+ <!-- ===== HEADER AREA ===== -->
50
+ <div class="eg-header-area" :style="headerAreaStyle">
51
+
52
+ <!-- Corner cell -->
53
+ <div
54
+ class="eg-corner"
55
+ :style="cornerStyle"
56
+ @click="selectAll"
57
+ ></div>
58
+
59
+ <!-- Row group outline gutter (left) -->
60
+ <div v-if="rowGroups.length" class="eg-row-outline-gutter" :style="rowOutlineGutterStyle"></div>
61
+
62
+ <!-- Frozen columns header -->
63
+ <div v-if="getFrozenWidth() > 0" class="eg-frozen-col-header" :style="frozenColHeaderStyle">
64
+ <div :style="{ position: 'relative', height: (headerRowHeight * headerDepth) + 'px' }">
65
+ <template v-for="(row, rowIdx) in headerRows">
66
+ <template v-for="cell in row">
67
+ <template v-if="isCellVisible(cell, rowIdx, true)">
68
+ <div
69
+ :key="'fh-' + rowIdx + '-' + (cell.column.key || cell.column.title)"
70
+ class="eg-header-cell"
71
+ :class="{ 'eg-header-group': cell.isGroup }"
72
+ :style="getHeaderCellStyle(cell, rowIdx, true)"
73
+ @mousedown="onColHeaderMouseDown($event, cell)"
74
+ @dblclick="onColHeaderDblClick($event, cell)"
75
+ @contextmenu.prevent="onColHeaderContextMenu($event, cell)"
76
+ >
77
+ <slot v-if="!cell.isGroup" :name="'header-' + cell.column.key" :column="cell.column">
78
+ {{ cell.column.title }}
79
+ </slot>
80
+ <span v-else>{{ cell.column.title }}</span>
81
+ <div
82
+ v-if="!cell.isGroup && cell.rowspan === headerDepth"
83
+ class="eg-col-resize-handle"
84
+ @mousedown.stop="startColResize($event, cell.column._flatIndex)"
85
+ ></div>
86
+ </div>
87
+ </template>
88
+ </template>
89
+ </template>
90
+ </div>
91
+ </div>
92
+
93
+ <!-- Scrollable columns header -->
94
+ <div class="eg-scroll-col-header" ref="scrollColHeader" :style="scrollColHeaderStyle">
95
+ <div class="eg-scroll-col-header-inner" :style="{ width: scrollContentWidth + 'px', height: (headerRowHeight * headerDepth) + 'px', position: 'relative' }">
96
+ <template v-for="(row, rowIdx) in headerRows">
97
+ <template v-for="cell in row">
98
+ <template v-if="isCellVisible(cell, rowIdx, false)">
99
+ <div
100
+ :key="'sh-' + rowIdx + '-' + (cell.column.key || cell.column.title)"
101
+ class="eg-header-cell"
102
+ :class="{ 'eg-header-group': cell.isGroup }"
103
+ :style="getHeaderCellStyle(cell, rowIdx, false)"
104
+ @mousedown="onColHeaderMouseDown($event, cell)"
105
+ @dblclick="onColHeaderDblClick($event, cell)"
106
+ @contextmenu.prevent="onColHeaderContextMenu($event, cell)"
107
+ >
108
+ <slot v-if="!cell.isGroup" :name="'header-' + cell.column.key" :column="cell.column">
109
+ {{ cell.column.title }}
110
+ </slot>
111
+ <span v-else>{{ cell.column.title }}</span>
112
+ <div
113
+ v-if="!cell.isGroup && cell.rowspan === headerDepth"
114
+ class="eg-col-resize-handle"
115
+ @mousedown.stop="startColResize($event, cell.column._flatIndex)"
116
+ ></div>
117
+ </div>
118
+ </template>
119
+ </template>
120
+ </template>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Pinned Right columns header -->
125
+ <div v-if="hasPinnedRight" class="eg-pinned-right-header" :style="pinnedRightHeaderStyle">
126
+ <div :style="{ position: 'relative', height: (headerRowHeight * headerDepth) + 'px' }">
127
+ <template v-for="(row, rowIdx) in headerRows">
128
+ <template v-for="cell in row">
129
+ <template v-if="isCellInZone(cell, rowIdx, 'right')">
130
+ <div
131
+ :key="'prh-' + rowIdx + '-' + (cell.column.key || cell.column.title)"
132
+ class="eg-header-cell"
133
+ :class="{ 'eg-header-group': cell.isGroup }"
134
+ :style="getHeaderCellStyleForZone(cell, rowIdx, 'right')"
135
+ @mousedown="onColHeaderMouseDown($event, cell)"
136
+ @dblclick="onColHeaderDblClick($event, cell)"
137
+ @contextmenu.prevent="onColHeaderContextMenu($event, cell)"
138
+ >
139
+ <slot v-if="!cell.isGroup" :name="'header-' + cell.column.key" :column="cell.column">
140
+ {{ cell.column.title }}
141
+ </slot>
142
+ <span v-else>{{ cell.column.title }}</span>
143
+ <div
144
+ v-if="!cell.isGroup && cell.rowspan === headerDepth"
145
+ class="eg-col-resize-handle"
146
+ @mousedown.stop="startColResize($event, cell.column._flatIndex)"
147
+ ></div>
148
+ </div>
149
+ </template>
150
+ </template>
151
+ </template>
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ <!-- ===== BODY AREA ===== -->
157
+ <div class="eg-body-area" :style="bodyAreaStyle">
158
+
159
+ <!-- Row number header column -->
160
+ <div class="eg-row-header-col" :style="rowHeaderColStyle">
161
+ <!-- Scrollable row headers -->
162
+ <div class="eg-scroll-row-headers" :style="scrollRowHeadersStyle" ref="scrollRowHeaders">
163
+ <div :style="{ height: totalVirtualHeight + 'px', position: 'relative' }">
164
+ <div :style="{ transform: 'translateY(' + virtualOffsetTop + 'px)' }">
165
+ <template v-for="ri in visibleRowRange">
166
+ <div
167
+ v-if="!isRowHidden(ri)"
168
+ :key="'srh-' + ri"
169
+ class="eg-row-header-cell"
170
+ :style="getRowHeaderStyle(ri)"
171
+ @mousedown="onRowHeaderMouseDown($event, ri)"
172
+ @contextmenu.prevent="onRowContextMenu($event, ri)"
173
+ >
174
+ {{ ri + 1 }}
175
+ <div class="eg-row-resize-handle" @mousedown.stop="startRowResize($event, ri)"></div>
176
+ </div>
177
+ </template>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </div>
182
+
183
+ <!-- Row group outline gutter (left side of rows) -->
184
+ <div v-if="rowGroups.length" class="eg-row-outline-col" :style="rowOutlineColStyle">
185
+ <template v-for="group in rowGroups">
186
+ <div
187
+ :key="'rg-' + group.startIndex + '-' + group.endIndex"
188
+ class="eg-group-btn"
189
+ :class="{ collapsed: isGroupCollapsed(group) }"
190
+ :style="getRowGroupBtnStyle(group)"
191
+ @click="toggleGroup(group)"
192
+ >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>
193
+ </template>
194
+ </div>
195
+
196
+ <!-- Main scroll body -->
197
+ <div class="eg-scroll-body" ref="scrollBody" :style="scrollBodyStyle" @scroll="onBodyScroll">
198
+ <div class="eg-scroll-body-inner" :style="{ width: scrollContentWidth + 'px', height: totalVirtualHeight + 'px', position: 'relative' }">
199
+
200
+ <!-- Pinned left column body -->
201
+ <div v-if="getFrozenWidth() > 0" class="eg-frozen-col-body" :style="frozenColBodyStyle">
202
+ <div :style="{ height: totalVirtualHeight + 'px', position: 'relative' }">
203
+ <div :style="{ transform: 'translateY(' + virtualOffsetTop + 'px)' }">
204
+ <template v-for="ri in visibleRowRange">
205
+ <template v-if="!isRowHidden(ri)">
206
+ <div :key="'fc-row-' + ri" class="eg-row" :style="getRowStyle(ri)">
207
+ <template v-for="(col, ci) in leafCols">
208
+ <template v-if="isColumnPinnedLeft(ci) && !isColHidden(ci)">
209
+ <div
210
+ :key="'fc-cell-' + ri + '-' + ci"
211
+ class="eg-cell"
212
+ :class="getCellClasses(ri, ci)"
213
+ :style="getCellStyle(ri, ci)"
214
+ @mousedown="onCellMouseDown($event, ri, ci)"
215
+ @mouseover="onCellMouseOver($event, ri, ci)"
216
+ @mouseup="onCellMouseUp"
217
+ @dblclick="onCellDblClick(ri, ci)"
218
+ @contextmenu.prevent="onCellContextMenu($event, ri, ci)"
219
+ >
220
+ <div v-if="isMergedAway(ri, ci)" style="display:none"></div>
221
+ <template v-else-if="isEditing(ri, ci)">
222
+ <input ref="editInput" class="eg-cell-input" v-model="editValue" @blur="commitEdit" @keydown.stop="onEditKeyDown" />
223
+ </template>
224
+ <template v-else-if="col.type === 'checkbox'">
225
+ <input
226
+ type="checkbox"
227
+ class="eg-cell-checkbox-input"
228
+ :checked="isCheckboxChecked(ri, ci)"
229
+ :disabled="!isCellEditable(ri, ci)"
230
+ @change="onCheckboxChange($event, ri, ci)"
231
+ />
232
+ </template>
233
+ <template v-else>
234
+ <slot :name="'cell-' + col.key" :row="_dataRaw[ri]" :rowIndex="ri" :column="col" :value="getCellValue(ri, ci)">
235
+ {{ col.type === 'select' ? getSelectLabel(ci, getCellValue(ri, ci)) : getCellValue(ri, ci) }}
236
+ </slot>
237
+ </template>
238
+ </div>
239
+ </template>
240
+ </template>
241
+ </div>
242
+ </template>
243
+ </template>
244
+ </div>
245
+ </div>
246
+ </div>
247
+
248
+ <!-- Main data cells -->
249
+ <div class="eg-main-cells" :style="mainCellsStyle">
250
+ <div :style="{ height: totalVirtualHeight + 'px', position: 'relative', width: scrollableContentWidth + 'px' }">
251
+ <div :style="{ transform: 'translateY(' + virtualOffsetTop + 'px)' }">
252
+ <template v-for="ri in visibleRowRange">
253
+ <template v-if="!isRowHidden(ri)">
254
+ <div
255
+ :key="'row-' + ri"
256
+ class="eg-row"
257
+ :style="getRowStyle(ri)"
258
+ :data-row="ri"
259
+ >
260
+ <template v-for="(col, ci) in leafCols">
261
+ <template v-if="isColumnScrollable(ci) && !isColHidden(ci)">
262
+ <div
263
+ :key="'cell-' + ri + '-' + ci"
264
+ class="eg-cell"
265
+ :class="getCellClasses(ri, ci)"
266
+ :style="getCellStyle(ri, ci)"
267
+ @mousedown="onCellMouseDown($event, ri, ci)"
268
+ @mouseover="onCellMouseOver($event, ri, ci)"
269
+ @mouseup="onCellMouseUp"
270
+ @dblclick="onCellDblClick(ri, ci)"
271
+ @contextmenu.prevent="onCellContextMenu($event, ri, ci)"
272
+ >
273
+ <div v-if="isMergedAway(ri, ci)" style="display:none"></div>
274
+ <template v-else-if="isEditing(ri, ci)">
275
+ <input
276
+ ref="editInput"
277
+ class="eg-cell-input"
278
+ v-model="editValue"
279
+ @blur="commitEdit"
280
+ @keydown.stop="onEditKeyDown"
281
+ />
282
+ </template>
283
+ <template v-else-if="col.type === 'checkbox'">
284
+ <input
285
+ type="checkbox"
286
+ class="eg-cell-checkbox-input"
287
+ :checked="isCheckboxChecked(ri, ci)"
288
+ :disabled="!isCellEditable(ri, ci)"
289
+ @change="onCheckboxChange($event, ri, ci)"
290
+ />
291
+ </template>
292
+ <template v-else>
293
+ <slot :name="'cell-' + col.key" :row="_dataRaw[ri]" :rowIndex="ri" :column="col" :value="getCellValue(ri, ci)">
294
+ {{ col.type === 'select' ? getSelectLabel(ci, getCellValue(ri, ci)) : getCellValue(ri, ci) }}
295
+ </slot>
296
+ </template>
297
+ </div>
298
+ </template>
299
+ </template>
300
+ </div>
301
+ </template>
302
+ </template>
303
+ </div>
304
+ </div>
305
+ </div>
306
+
307
+ <!-- Pinned Right column body -->
308
+ <div v-if="hasPinnedRight" class="eg-pinned-right-body" :style="pinnedRightBodyStyle">
309
+ <div :style="{ height: totalVirtualHeight + 'px', position: 'relative' }">
310
+ <div :style="{ transform: 'translateY(' + virtualOffsetTop + 'px)' }">
311
+ <template v-for="ri in visibleRowRange">
312
+ <template v-if="!isRowHidden(ri)">
313
+ <div :key="'pr-row-' + ri" class="eg-row" :style="getRowStyle(ri)">
314
+ <template v-for="col in pinnedRightCols">
315
+ <div
316
+ :key="'pr-cell-' + ri + '-' + col._flatIndex"
317
+ class="eg-cell"
318
+ :class="getCellClasses(ri, col._flatIndex)"
319
+ :style="getCellStyle(ri, col._flatIndex)"
320
+ @mousedown="onCellMouseDown($event, ri, col._flatIndex)"
321
+ @mouseover="onCellMouseOver($event, ri, col._flatIndex)"
322
+ @mouseup="onCellMouseUp"
323
+ @dblclick="onCellDblClick(ri, col._flatIndex)"
324
+ @contextmenu.prevent="onCellContextMenu($event, ri, col._flatIndex)"
325
+ >
326
+ <div v-if="isMergedAway(ri, col._flatIndex)" style="display:none"></div>
327
+ <template v-else-if="isEditing(ri, col._flatIndex)">
328
+ <input ref="editInput" class="eg-cell-input" v-model="editValue" @blur="commitEdit" @keydown.stop="onEditKeyDown" />
329
+ </template>
330
+ <template v-else-if="col.type === 'checkbox'">
331
+ <input
332
+ type="checkbox"
333
+ class="eg-cell-checkbox-input"
334
+ :checked="isCheckboxChecked(ri, col._flatIndex)"
335
+ :disabled="!isCellEditable(ri, col._flatIndex)"
336
+ @change="onCheckboxChange($event, ri, col._flatIndex)"
337
+ />
338
+ </template>
339
+ <template v-else>
340
+ <slot :name="'cell-' + col.key" :row="_dataRaw[ri]" :rowIndex="ri" :column="col" :value="getCellValue(ri, col._flatIndex)">
341
+ {{ col.type === 'select' ? getSelectLabel(col._flatIndex, getCellValue(ri, col._flatIndex)) : getCellValue(ri, col._flatIndex) }}
342
+ </slot>
343
+ </template>
344
+ </div>
345
+ </template>
346
+ </div>
347
+ </template>
348
+ </template>
349
+ </div>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- Fill handle (shown on primary range) -->
354
+ <div
355
+ v-if="fillHandleVisible"
356
+ class="eg-fill-handle"
357
+ :style="fillHandleStyle"
358
+ @mousedown.stop="startFillDrag"
359
+ ></div>
360
+
361
+ <!-- Drag move ghost border -->
362
+ <div
363
+ v-if="dragMove.active"
364
+ class="eg-drag-ghost"
365
+ :style="dragGhostStyle"
366
+ ></div>
367
+ </div>
368
+ </div>
369
+
370
+ <!-- Column group outline row (top, above body) -->
371
+ <div v-if="colGroups.length" class="eg-col-outline-row" :style="colOutlineRowStyle">
372
+ <template v-for="group in colGroups">
373
+ <div
374
+ :key="'cg-' + group.startIndex + '-' + group.endIndex"
375
+ class="eg-group-btn"
376
+ :class="{ collapsed: isGroupCollapsed(group) }"
377
+ :style="getColGroupBtnStyle(group)"
378
+ @click="toggleGroup(group)"
379
+ >{{ isGroupCollapsed(group) ? '+' : '−' }}</div>
380
+ </template>
381
+ </div>
382
+
383
+ </div><!-- end body-area -->
384
+
385
+ <!-- Resize indicator line -->
386
+ <div v-if="resizing.active" class="eg-resize-line" :style="resizeLineStyle"></div>
387
+ </div><!-- end container -->
388
+
389
+ <!-- Import progress overlay -->
390
+ <div v-if="importState.active" class="eg-import-overlay">
391
+ <div class="eg-import-dialog">
392
+ <div class="eg-import-title">Fayl import qilinmoqda</div>
393
+ <div class="eg-import-bar-wrap">
394
+ <div class="eg-import-bar" :style="{ width: importState.percent + '%' }"></div>
395
+ </div>
396
+ <div class="eg-import-msg">{{ importState.message }}</div>
397
+ <div class="eg-import-pct">{{ importState.percent }}%</div>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- Export progress overlay -->
402
+ <div v-if="exportState.active" class="eg-import-overlay">
403
+ <div class="eg-import-dialog">
404
+ <div class="eg-import-title">Fayl export qilinmoqda...</div>
405
+ <div class="eg-export-spinner"></div>
406
+ </div>
407
+ </div>
408
+
409
+ <!-- Select dropdown overlay -->
410
+ <div
411
+ v-if="selectDropdown"
412
+ class="eg-select-dropdown"
413
+ :style="dropdownStyle"
414
+ @mousedown.prevent
415
+ >
416
+ <div
417
+ v-for="(opt, idx) in selectDropdown.options"
418
+ :key="opt.value"
419
+ class="eg-select-option"
420
+ :class="{
421
+ 'eg-select-option--active': opt.value === selectDropdown.currentValue,
422
+ 'eg-select-option--highlighted': idx === selectDropdown.highlight
423
+ }"
424
+ @mousedown.prevent="commitSelectValue(opt.value)"
425
+ @mouseover="selectDropdown.highlight = idx"
426
+ >
427
+ <span v-if="opt.color" class="eg-select-dot" :style="{ background: opt.color }"></span>
428
+ {{ opt.label }}
429
+ </div>
430
+ </div>
431
+
432
+ <!-- Validation error tooltip (cellValidator) -->
433
+ <div
434
+ v-if="invalidCell && invalidCell.rect"
435
+ class="eg-validation-tooltip"
436
+ :style="invalidTooltipStyle"
437
+ >{{ invalidCell.message }}</div>
438
+ </div>
439
+ </template>
440
+
441
+ <script>
442
+ import {
443
+ normalizeRange, isCellInRange, isCellInAnyRange,
444
+ buildRange, singleCellRange, getPrimaryRange
445
+ } from '../utils/selection.js'
446
+ import { flattenColumns, buildHeaderRows, columnTreeDepth } from '../utils/headerTree.js'
447
+ import { calcVisibleRows, buildCumulativeHeights, findVisibleRange, getRowTop, getRowHeight } from '../utils/virtualScroll.js'
448
+ import { generateFillValues } from '../utils/fillHandle.js'
449
+ import {
450
+ matrixToTSV, tsvToMatrix, extractMatrix, transposeMatrix, applyMatrix, tileMatrix
451
+ } from '../utils/clipboard.js'
452
+ import {
453
+ mergeHasConflict, findMergeAtCell, isMergeAnchor, addMerge,
454
+ removeMergeAt, resolveMergeAnchor
455
+ } from '../utils/merge.js'
456
+ import {
457
+ addGroup, removeGroup, toggleGroupCollapse, isGroupCollapsed as _isGroupCollapsed,
458
+ getCollapsedRowIndices, getCollapsedColIndices
459
+ } from '../utils/outline.js'
460
+ import { exportToExcel as _exportToExcel } from '../utils/excelExport.js'
461
+ import { importFromFile as _importFromFile } from '../utils/excelImport.js'
462
+ import { createExcelImportWorker, createExcelExportWorker } from '../utils/workerFactory'
463
+ import { getMessages } from '../utils/i18n.js'
464
+
465
+ const ROW_HEIGHT = 28
466
+ const HEADER_ROW_HEIGHT = 28
467
+ const ROW_HEADER_WIDTH = 50
468
+ const DEFAULT_COL_WIDTH = 100
469
+ const OUTLINE_GUTTER = 20
470
+
471
+ export default {
472
+ name: 'ExcelGrid',
473
+
474
+ // v-model → :data + @input
475
+ model: { prop: 'data', event: 'input' },
476
+
477
+ props: {
478
+ /** Array of row objects keyed by columns[].key */
479
+ data: { type: Array, default: () => [] },
480
+
481
+ /** Column definitions (may be nested via children[]) */
482
+ columns: { type: Array, default: () => [] },
483
+
484
+ /** Indices of hidden leaf columns */
485
+ hiddenColumns: { type: Array, default: () => [] },
486
+
487
+ /** Indices of hidden rows */
488
+ hiddenRows: { type: Array, default: () => [] },
489
+
490
+ /** Initial merge descriptors [{ startRow, startCol, endRow, endCol }] */
491
+ merges: { type: Array, default: () => [] },
492
+
493
+ /** Collapsed group descriptors [{ type, startIndex, endIndex }] */
494
+ collapsedGroups: { type: Array, default: () => [] },
495
+
496
+ /** Height of the grid container in px */
497
+ height: { type: Number, default: 400 },
498
+
499
+ /** Width of the grid container (px or 'auto') */
500
+ width: { type: [Number, String], default: '100%' },
501
+
502
+ /**
503
+ * Called before drag-drop: (sourceRange, targetRange) => bool
504
+ * Return false to cancel the drop.
505
+ */
506
+ beforeDrop: { type: Function, default: null },
507
+
508
+ /**
509
+ * UI locale for the context menu.
510
+ * Supported: 'en' | 'ru' | 'uz' | 'uz-Cyrl'
511
+ */
512
+ locale: { type: String, default: 'en' },
513
+
514
+ /**
515
+ * Pinned columns map: { [colKey]: 'left' | 'right' }
516
+ * Pinned-left columns render as a fixed zone on the left.
517
+ * Pinned-right columns render as a fixed zone on the right.
518
+ * Emit 'update:pinnedColumns' to support .sync modifier.
519
+ */
520
+ pinnedColumns: { type: Object, default: () => ({}) },
521
+
522
+ /**
523
+ * Dynamic cell editability: (row, rowIndex, col, colIndex) => boolean
524
+ * Overrides column-level editable when provided.
525
+ * row = plain data object, col = column definition.
526
+ */
527
+ cellEditable: { type: Function, default: null },
528
+
529
+ /**
530
+ * Cell value validation: (value, row, rowIndex, col, colIndex) => boolean
531
+ * Return false to reject the write — the cell keeps its old value and
532
+ * an error tooltip is shown anchored to that cell.
533
+ */
534
+ cellValidator: { type: Function, default: null },
535
+
536
+ /**
537
+ * Error tooltip text for a rejected value.
538
+ * Function: (value, row, rowIndex, col, colIndex) => string
539
+ * String: same message for every rejected cell.
540
+ * Default: locale's "invalidValue" message.
541
+ */
542
+ cellValidationMessage: { type: [Function, String], default: null },
543
+
544
+ /**
545
+ * Bulk-write strictness (paste / fill handle / Delete).
546
+ * false (default): invalid cells in the affected range are skipped,
547
+ * the rest of the operation still applies.
548
+ * true: if ANY cell in the affected range fails validation, the whole
549
+ * bulk operation is rejected — nothing is written.
550
+ */
551
+ strictBulkValidation: { type: Boolean, default: false }
552
+ },
553
+
554
+ data() {
555
+ return {
556
+ // Selection
557
+ activeCell: { row: 0, col: 0 },
558
+ selectionRanges: [], // [{ startRow, startCol, endRow, endCol }]
559
+ isMouseDown: false,
560
+ mouseDownRange: null,
561
+ isCtrlDrag: false,
562
+
563
+ // Edit
564
+ editingCell: null, // { row, col }
565
+ editValue: '',
566
+ editOriginalValue: '',
567
+
568
+ // Scroll
569
+ scrollTop: 0,
570
+ scrollLeft: 0,
571
+ headerViewportWidth: null,
572
+
573
+ // Virtual scroll
574
+ virtualStartIndex: 0,
575
+ virtualEndIndex: 0,
576
+ virtualOffsetTop: 0,
577
+ totalVirtualHeight: 0,
578
+
579
+ // Column widths (flat index → px)
580
+ colWidths: {},
581
+
582
+ // Row heights (row index → px)
583
+ rowHeights: {},
584
+
585
+ // Import/export progress
586
+ importState: { active: false, percent: 0, message: '' },
587
+ exportState: { active: false },
588
+
589
+ // Resize state
590
+ resizing: {
591
+ active: false,
592
+ type: null, // 'col' | 'row'
593
+ index: -1,
594
+ startX: 0,
595
+ startY: 0,
596
+ startSize: 0,
597
+ currentPos: 0
598
+ },
599
+
600
+ // Fill handle
601
+ fillDrag: {
602
+ active: false,
603
+ sourceRange: null,
604
+ currentRow: -1,
605
+ currentCol: -1
606
+ },
607
+
608
+ // Drag-move
609
+ dragMove: {
610
+ active: false,
611
+ sourceRange: null,
612
+ startX: 0,
613
+ startY: 0,
614
+ targetRow: -1,
615
+ targetCol: -1,
616
+ copyMode: false
617
+ },
618
+
619
+ // Clipboard (internal)
620
+ clipboardMatrix: null,
621
+ clipboardRange: null,
622
+ clipboardMode: null, // 'copy' | 'cut'
623
+
624
+ // Context menu
625
+ contextMenu: { visible: false, x: 0, y: 0, row: -1, col: -1 },
626
+
627
+ // Select dropdown
628
+ selectDropdown: null, // { row, col, options, currentValue, rect }
629
+
630
+ // Cell value validation (cellValidator)
631
+ invalidCell: null, // { row, col, message, rect }
632
+
633
+ // Merges (controlled via prop + update:merges)
634
+ internalMerges: [],
635
+
636
+ // Groups
637
+ internalGroups: [], // [{ type, startIndex, endIndex }]
638
+ internalCollapsedGroups: [],
639
+
640
+ // Header
641
+ headerDepth: 1,
642
+ headerRows: [],
643
+
644
+ // Leaf columns cache
645
+ leafCols: []
646
+ }
647
+ },
648
+
649
+ computed: {
650
+ t() { return getMessages(this.locale) },
651
+
652
+ pinnedLeftSet() {
653
+ const s = new Set()
654
+ this.leafCols.forEach(col => { if (this.pinnedColumns[col.key] === 'left') s.add(col._flatIndex) })
655
+ return s
656
+ },
657
+
658
+ pinnedRightSet() {
659
+ const s = new Set()
660
+ this.leafCols.forEach(col => { if (this.pinnedColumns[col.key] === 'right') s.add(col._flatIndex) })
661
+ return s
662
+ },
663
+
664
+ pinnedLeftCols() { return this.leafCols.filter(col => this.pinnedLeftSet.has(col._flatIndex)) },
665
+ pinnedRightCols() { return this.leafCols.filter(col => this.pinnedRightSet.has(col._flatIndex)) },
666
+
667
+ hasPinnedLeft() { return this.pinnedLeftSet.size > 0 },
668
+ hasPinnedRight() { return this.pinnedRightSet.size > 0 },
669
+
670
+ pinnedLeftWidth() {
671
+ let w = 0
672
+ for (const col of this.pinnedLeftCols) {
673
+ if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)
674
+ }
675
+ return w
676
+ },
677
+
678
+ pinnedRightWidth() {
679
+ let w = 0
680
+ for (const col of this.pinnedRightCols) {
681
+ if (!this.isColHidden(col._flatIndex)) w += this.getColW(col._flatIndex)
682
+ }
683
+ return w
684
+ },
685
+
686
+ totalRows() { return this.data.length },
687
+
688
+ headerRowHeight() { return HEADER_ROW_HEIGHT },
689
+
690
+ // Pre-built cumulative height array — O(n) once, then cached by Vue.
691
+ // findVisibleRange() uses binary search against this cached array → O(log n) per scroll.
692
+ // Invalidated only when row count, custom heights, or hidden rows change.
693
+ cumulativeHeights() {
694
+ return buildCumulativeHeights(this.totalRows, ROW_HEIGHT, this.rowHeights, this.hiddenRowSet)
695
+ },
696
+
697
+ hiddenColSet() { return new Set(this.hiddenColumns) },
698
+
699
+ hiddenRowSet() {
700
+ const s = new Set(this.hiddenRows)
701
+ // Also add rows hidden by collapsed row groups
702
+ const collapsed = getCollapsedRowIndices(this.internalGroups, this.internalCollapsedGroups)
703
+ collapsed.forEach(i => s.add(i))
704
+ return s
705
+ },
706
+
707
+ hiddenColFromGroupSet() {
708
+ return getCollapsedColIndices(this.internalGroups, this.internalCollapsedGroups)
709
+ },
710
+
711
+ effectiveHiddenColSet() {
712
+ const s = new Set(this.hiddenColSet)
713
+ this.hiddenColFromGroupSet.forEach(i => s.add(i))
714
+ return s
715
+ },
716
+
717
+ rowGroups() { return this.internalGroups.filter(g => g.type === 'row') },
718
+ colGroups() { return this.internalGroups.filter(g => g.type === 'column') },
719
+
720
+ visibleRowRange() {
721
+ const rows = []
722
+ for (let i = this.virtualStartIndex; i <= this.virtualEndIndex; i++) rows.push(i)
723
+ return rows
724
+ },
725
+
726
+ primaryRange() {
727
+ return getPrimaryRange(this.selectionRanges, this.activeCell)
728
+ },
729
+
730
+ fillHandleVisible() {
731
+ return this.primaryRange && !this.editingCell
732
+ },
733
+
734
+ fillHandleStyle() {
735
+ if (!this.primaryRange) return {}
736
+ const r = normalizeRange(this.primaryRange)
737
+ // O(1) lookup via cumulativeHeights instead of O(endRow) getRowTop loop
738
+ const bottom = this.cumulativeHeights[r.endRow + 1] || 0
739
+ const right = this.getColLeft(r.endCol + 1)
740
+ return {
741
+ position: 'absolute',
742
+ width: '6px',
743
+ height: '6px',
744
+ background: '#1a73e8',
745
+ border: '1px solid white',
746
+ bottom: (this.totalVirtualHeight - bottom + 1) + 'px',
747
+ right: (this.scrollContentWidth - right + 1) + 'px',
748
+ cursor: 'crosshair',
749
+ zIndex: 10
750
+ }
751
+ },
752
+
753
+ dropdownStyle() {
754
+ if (!this.selectDropdown) return {}
755
+ const { rect, options } = this.selectDropdown
756
+ const approxHeight = Math.min(options.length * 32 + 8, 200)
757
+ const spaceBelow = window.innerHeight - rect.bottom
758
+ const top = spaceBelow >= approxHeight ? rect.bottom : rect.top - approxHeight
759
+ return {
760
+ position: 'fixed',
761
+ top: top + 'px',
762
+ left: rect.left + 'px',
763
+ minWidth: Math.max(rect.width, 120) + 'px',
764
+ zIndex: 9999
765
+ }
766
+ },
767
+
768
+ invalidTooltipStyle() {
769
+ if (!this.invalidCell || !this.invalidCell.rect) return {}
770
+ const { rect } = this.invalidCell
771
+ return {
772
+ position: 'fixed',
773
+ top: (rect.bottom + 4) + 'px',
774
+ left: rect.left + 'px',
775
+ zIndex: 9999
776
+ }
777
+ },
778
+
779
+ dragGhostStyle() {
780
+ if (!this.dragMove.active || this.dragMove.targetRow < 0) return {}
781
+ const r = normalizeRange(this.dragMove.sourceRange)
782
+ const rows = r.endRow - r.startRow
783
+ const cols = r.endCol - r.startCol
784
+ // O(1) lookup via cumulativeHeights instead of O(targetRow) getRowTop loop
785
+ const top = this.cumulativeHeights[this.dragMove.targetRow] || 0
786
+ const left = this.getColLeft(this.dragMove.targetCol)
787
+ const width = this.getRangeWidth(this.dragMove.targetCol, this.dragMove.targetCol + cols)
788
+ const height = this.getRangeHeight(this.dragMove.targetRow, this.dragMove.targetRow + rows)
789
+ return {
790
+ position: 'absolute',
791
+ top: top + 'px',
792
+ left: left + 'px',
793
+ width: width + 'px',
794
+ height: height + 'px',
795
+ border: '2px dashed #1a73e8',
796
+ pointerEvents: 'none',
797
+ zIndex: 20
798
+ }
799
+ },
800
+
801
+ wrapperStyle() {
802
+ return {
803
+ width: typeof this.width === 'number' ? this.width + 'px' : this.width,
804
+ height: this.height + 'px',
805
+ position: 'relative',
806
+ outline: 'none',
807
+ userSelect: 'none'
808
+ }
809
+ },
810
+
811
+ headerAreaStyle() {
812
+ return {
813
+ display: 'flex',
814
+ flexDirection: 'row',
815
+ position: 'sticky',
816
+ top: 0,
817
+ zIndex: 30,
818
+ background: '#f8f9fa'
819
+ }
820
+ },
821
+
822
+ cornerStyle() {
823
+ const outlineW = this.rowGroups.length ? OUTLINE_GUTTER : 0
824
+ return {
825
+ width: (ROW_HEADER_WIDTH + outlineW) + 'px',
826
+ minWidth: (ROW_HEADER_WIDTH + outlineW) + 'px',
827
+ height: (HEADER_ROW_HEIGHT * this.headerDepth) + 'px',
828
+ background: '#f0f0f0',
829
+ borderRight: '1px solid #ccc',
830
+ borderBottom: '1px solid #ccc',
831
+ boxSizing: 'border-box',
832
+ flexShrink: 0
833
+ }
834
+ },
835
+
836
+ rowOutlineGutterStyle() {
837
+ return { width: OUTLINE_GUTTER + 'px', flexShrink: 0 }
838
+ },
839
+
840
+ frozenColHeaderStyle() {
841
+ const w = this.getFrozenWidth()
842
+ return {
843
+ width: w + 'px',
844
+ minWidth: w + 'px',
845
+ overflow: 'hidden',
846
+ flexShrink: 0
847
+ }
848
+ },
849
+
850
+ scrollColHeaderStyle() {
851
+ // Once measured, lock the header to the body's exact clientWidth (flex: 0 0 <px>)
852
+ // instead of growing independently via flex:1 — this guarantees identical
853
+ // scrollWidth-clientWidth (max scrollLeft) on both, so syncing scrollLeft never clamps
854
+ // one before the other. (padding-right can't do this: clientWidth always includes
855
+ // padding, so padding never actually shrinks it.)
856
+ if (this.headerViewportWidth != null) {
857
+ return { flex: '0 0 ' + this.headerViewportWidth + 'px', overflow: 'hidden' }
858
+ }
859
+ return { flex: 1, overflow: 'hidden' }
860
+ },
861
+
862
+ bodyAreaStyle() {
863
+ return {
864
+ display: 'flex',
865
+ flexDirection: 'row',
866
+ flex: 1,
867
+ position: 'relative'
868
+ }
869
+ },
870
+
871
+ rowHeaderColStyle() {
872
+ return {
873
+ width: ROW_HEADER_WIDTH + 'px',
874
+ minWidth: ROW_HEADER_WIDTH + 'px',
875
+ flexShrink: 0,
876
+ display: 'flex',
877
+ flexDirection: 'column',
878
+ position: 'sticky',
879
+ left: 0,
880
+ zIndex: 20,
881
+ background: '#f0f0f0'
882
+ }
883
+ },
884
+
885
+ rowOutlineColStyle() {
886
+ return {
887
+ width: OUTLINE_GUTTER + 'px',
888
+ minWidth: OUTLINE_GUTTER + 'px',
889
+ flexShrink: 0,
890
+ position: 'relative',
891
+ background: '#fafafa'
892
+ }
893
+ },
894
+
895
+ scrollBodyStyle() {
896
+ return {
897
+ flex: 1,
898
+ overflow: 'auto',
899
+ position: 'relative'
900
+ }
901
+ },
902
+
903
+ frozenColBodyStyle() {
904
+ const w = this.getFrozenWidth()
905
+ return {
906
+ position: 'sticky',
907
+ left: 0,
908
+ width: w + 'px',
909
+ zIndex: 10,
910
+ background: 'white',
911
+ overflow: 'hidden'
912
+ }
913
+ },
914
+
915
+ mainCellsStyle() {
916
+ const left = this.getFrozenWidth()
917
+ return {
918
+ position: 'absolute',
919
+ left: left + 'px',
920
+ top: 0,
921
+ bottom: 0
922
+ }
923
+ },
924
+
925
+ scrollContentWidth() {
926
+ let w = 0
927
+ for (let i = 0; i < this.leafCols.length; i++) {
928
+ if (!this.isColHidden(i) && !this.pinnedRightSet.has(i)) w += this.getColW(i)
929
+ }
930
+ // Extra right padding so pinned-right zone doesn't cover the last scrollable column
931
+ if (this.hasPinnedRight) w += this.pinnedRightWidth
932
+ return w
933
+ },
934
+
935
+ scrollableContentWidth() {
936
+ let w = 0
937
+ for (let i = 0; i < this.leafCols.length; i++) {
938
+ if (!this.isColHidden(i) && this.isColumnScrollable(i)) w += this.getColW(i)
939
+ }
940
+ return w
941
+ },
942
+
943
+ pinnedRightBodyStyle() {
944
+ return {
945
+ position: 'sticky',
946
+ right: 0,
947
+ width: this.pinnedRightWidth + 'px',
948
+ zIndex: 10,
949
+ background: 'white',
950
+ flexShrink: 0
951
+ }
952
+ },
953
+
954
+ pinnedRightHeaderStyle() {
955
+ return {
956
+ width: this.pinnedRightWidth + 'px',
957
+ minWidth: this.pinnedRightWidth + 'px',
958
+ overflow: 'hidden',
959
+ flexShrink: 0,
960
+ borderLeft: '1px solid #ccc'
961
+ }
962
+ },
963
+
964
+ scrollRowHeadersStyle() {
965
+ return { flex: 1, overflow: 'hidden', position: 'relative' }
966
+ },
967
+
968
+ colOutlineRowStyle() {
969
+ return {
970
+ position: 'absolute',
971
+ top: -OUTLINE_GUTTER + 'px',
972
+ left: 0,
973
+ right: 0,
974
+ height: OUTLINE_GUTTER + 'px'
975
+ }
976
+ },
977
+
978
+ resizeLineStyle() {
979
+ if (!this.resizing.active) return {}
980
+ if (this.resizing.type === 'col') {
981
+ return {
982
+ position: 'fixed',
983
+ top: 0,
984
+ left: this.resizing.currentPos + 'px',
985
+ width: '2px',
986
+ height: '100%',
987
+ background: '#1a73e8',
988
+ pointerEvents: 'none',
989
+ zIndex: 9999
990
+ }
991
+ } else {
992
+ return {
993
+ position: 'fixed',
994
+ left: 0,
995
+ top: this.resizing.currentPos + 'px',
996
+ height: '2px',
997
+ width: '100%',
998
+ background: '#1a73e8',
999
+ pointerEvents: 'none',
1000
+ zIndex: 9999
1001
+ }
1002
+ }
1003
+ }
1004
+ },
1005
+
1006
+ watch: {
1007
+ columns: {
1008
+ immediate: true,
1009
+ handler(cols) {
1010
+ this.leafCols = flattenColumns(cols)
1011
+ this.headerDepth = columnTreeDepth(cols)
1012
+ this.headerRows = buildHeaderRows(cols, this.headerDepth)
1013
+ // Init colWidths
1014
+ this.leafCols.forEach(col => {
1015
+ if (this.colWidths[col._flatIndex] === undefined) {
1016
+ this.colWidths[col._flatIndex] = col.width || DEFAULT_COL_WIDTH
1017
+ }
1018
+ })
1019
+ }
1020
+ },
1021
+ merges: {
1022
+ immediate: true,
1023
+ handler(v) { this.internalMerges = v ? [...v] : [] }
1024
+ },
1025
+ collapsedGroups: {
1026
+ immediate: true,
1027
+ handler(v) { this.internalCollapsedGroups = v ? [...v] : [] }
1028
+ },
1029
+ data: {
1030
+ immediate: true,
1031
+ handler(newData) {
1032
+ // Keep _dataRaw in sync so template uses fresh data without triggering dependArray
1033
+ this._dataRaw = newData || []
1034
+ this.updateVirtualScroll()
1035
+ // Force re-render so cell values refresh even when virtualScroll indices don't change
1036
+ // (e.g. editing a cell: same row count, same scroll position, only content changed)
1037
+ this.$forceUpdate()
1038
+ }
1039
+ },
1040
+ totalRows() { this.updateVirtualScroll() },
1041
+ hiddenRowSet() { this.updateVirtualScroll() },
1042
+ totalVirtualHeight() { this.$nextTick(this.syncHeaderViewportWidth) }
1043
+ },
1044
+
1045
+ created() {
1046
+ // Non-reactive row data reference — avoids Vue 2 dependArray(data) O(n) loop.
1047
+ // Vue 2 calls dependArray() for EVERY `data[ri]` access in the template,
1048
+ // iterating all 50,000 rows each time. With 1200 cells per render that's
1049
+ // 60 million iterations per render (~600ms). _dataRaw bypasses this.
1050
+ this._dataRaw = this.data || []
1051
+
1052
+ // Non-reactive caches: same (w,h) → same style object → Vue 2 skips DOM style writes
1053
+ this._cellStyleCache = Object.create(null)
1054
+ this._rowStyleCache = Object.create(null)
1055
+ this._rowHeaderStyleCache = Object.create(null)
1056
+ // rAF handles for scroll/resize throttle
1057
+ this._scrollRAF = null
1058
+ this._resizeRAF = null
1059
+
1060
+ // Undo/redo history — non-reactive to avoid Vue overhead.
1061
+ // Each entry: { type: 'data'|'merges', before: {...}, after: {...} }
1062
+ // 'data' stores only diff rows: { indices: number[], rows: object[] }
1063
+ this._history = []
1064
+ this._historyIndex = -1
1065
+ this._historySkip = false // true while undo/redo is in progress
1066
+
1067
+ },
1068
+
1069
+ mounted() {
1070
+ this.updateVirtualScroll()
1071
+ this.$nextTick(this.syncHeaderViewportWidth)
1072
+ document.addEventListener('mousemove', this.onGlobalMouseMove)
1073
+ document.addEventListener('mouseup', this.onGlobalMouseUp)
1074
+ document.addEventListener('keydown', this.onGlobalKeyDown)
1075
+ document.addEventListener('mousedown', this._onDocMouseDownForDropdown, true)
1076
+ window.addEventListener('resize', this.syncHeaderViewportWidth)
1077
+ },
1078
+
1079
+ beforeDestroy() {
1080
+ document.removeEventListener('mousemove', this.onGlobalMouseMove)
1081
+ document.removeEventListener('mouseup', this.onGlobalMouseUp)
1082
+ document.removeEventListener('keydown', this.onGlobalKeyDown)
1083
+ document.removeEventListener('mousedown', this._onDocMouseDownForDropdown, true)
1084
+ window.removeEventListener('resize', this.syncHeaderViewportWidth)
1085
+ },
1086
+
1087
+ methods: {
1088
+ // =========================================================
1089
+ // VIRTUAL SCROLL
1090
+ // =========================================================
1091
+ updateVirtualScroll() {
1092
+ const containerH = this.height - (HEADER_ROW_HEIGHT * this.headerDepth)
1093
+ // Use binary search against the Vue-cached cumulativeHeights → O(log n) per scroll
1094
+ const result = findVisibleRange(
1095
+ this.scrollTop, Math.max(containerH, 100), this.totalRows, this.cumulativeHeights
1096
+ )
1097
+ this.virtualStartIndex = result.startIndex
1098
+ this.virtualEndIndex = result.endIndex
1099
+ this.virtualOffsetTop = result.offsetTop
1100
+ this.totalVirtualHeight = result.totalHeight
1101
+ },
1102
+
1103
+ onBodyScroll(e) {
1104
+ const target = e.target
1105
+ // Sync sibling scroll containers immediately (visual alignment, no layout trigger)
1106
+ if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = target.scrollLeft
1107
+ if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = target.scrollTop
1108
+
1109
+ // Throttle reactive state updates + virtual scroll recalc to one rAF per frame
1110
+ if (!this._scrollRAF) {
1111
+ this._scrollRAF = requestAnimationFrame(() => {
1112
+ this._scrollRAF = null
1113
+ this.scrollTop = target.scrollTop
1114
+ this.scrollLeft = target.scrollLeft
1115
+ this.updateVirtualScroll()
1116
+ })
1117
+ }
1118
+ },
1119
+
1120
+ onScroll(e) {},
1121
+
1122
+ // .eg-scroll-col-header (overflow:hidden, synced via JS) has no native scrollbar, but
1123
+ // .eg-scroll-body (overflow:auto) does — its real vertical scrollbar eats into its
1124
+ // clientWidth. If both containers just grow via flex:1, the header ends up wider than
1125
+ // the body, so its max scrollLeft is smaller and syncing clamps it before the body
1126
+ // reaches its rightmost scroll position — column borders desync right at the scroll
1127
+ // end. Locking the header to the body's exact measured clientWidth (scrollColHeaderStyle)
1128
+ // makes both max scrollLeft values identical.
1129
+ syncHeaderViewportWidth() {
1130
+ const el = this.$refs.scrollBody
1131
+ if (!el) return
1132
+ if (this.headerViewportWidth !== el.clientWidth) this.headerViewportWidth = el.clientWidth
1133
+ },
1134
+
1135
+ // =========================================================
1136
+ // HELPERS
1137
+ // =========================================================
1138
+ isRowHidden(ri) { return this.hiddenRowSet.has(ri) },
1139
+ isColHidden(ci) { return this.effectiveHiddenColSet.has(ci) },
1140
+
1141
+ getColW(ci) {
1142
+ return this.colWidths[ci] !== undefined ? this.colWidths[ci] : (this.leafCols[ci] ? this.leafCols[ci].width || DEFAULT_COL_WIDTH : DEFAULT_COL_WIDTH)
1143
+ },
1144
+
1145
+ getRowH(ri) {
1146
+ if (this.isRowHidden(ri)) return 0
1147
+ return this.rowHeights[ri] || ROW_HEIGHT
1148
+ },
1149
+
1150
+ getColLeft(ci) {
1151
+ let left = 0
1152
+ for (let i = 0; i < ci && i < this.leafCols.length; i++) {
1153
+ if (!this.isColHidden(i)) left += this.getColW(i)
1154
+ }
1155
+ return left
1156
+ },
1157
+
1158
+ getFrozenWidth() {
1159
+ return this.hasPinnedLeft ? this.pinnedLeftWidth : 0
1160
+ },
1161
+
1162
+ isColumnPinnedLeft(ci) {
1163
+ return this.pinnedLeftSet.has(ci)
1164
+ },
1165
+
1166
+ isColumnPinnedRight(ci) {
1167
+ return this.pinnedRightSet.has(ci)
1168
+ },
1169
+
1170
+ isColumnScrollable(ci) {
1171
+ return !this.isColumnPinnedLeft(ci) && !this.isColumnPinnedRight(ci)
1172
+ },
1173
+
1174
+ getZoneColLeft(ci, zone) {
1175
+ let left = 0
1176
+ if (zone === 'left') {
1177
+ for (const col of this.pinnedLeftCols) {
1178
+ if (col._flatIndex === ci) break
1179
+ if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)
1180
+ }
1181
+ } else if (zone === 'right') {
1182
+ for (const col of this.pinnedRightCols) {
1183
+ if (col._flatIndex === ci) break
1184
+ if (!this.isColHidden(col._flatIndex)) left += this.getColW(col._flatIndex)
1185
+ }
1186
+ } else {
1187
+ for (let i = 0; i < this.leafCols.length; i++) {
1188
+ const col = this.leafCols[i]
1189
+ if (col._flatIndex === ci) break
1190
+ if (!this.isColHidden(col._flatIndex) && this.isColumnScrollable(col._flatIndex)) {
1191
+ left += this.getColW(col._flatIndex)
1192
+ }
1193
+ }
1194
+ }
1195
+ return left
1196
+ },
1197
+
1198
+ isCellInZone(cell, rowIdx, zone) {
1199
+ if (cell.isGroup) {
1200
+ const start = cell._firstLeafFlatIndex
1201
+ const end = start + cell.colspan - 1
1202
+ for (let i = start; i <= end; i++) {
1203
+ if (this.isColHidden(i)) continue
1204
+ const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')
1205
+ if (z === zone) return true
1206
+ }
1207
+ return false
1208
+ }
1209
+ const ci = cell.column._flatIndex
1210
+ if (this.isColHidden(ci)) return false
1211
+ const z = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')
1212
+ return z === zone
1213
+ },
1214
+
1215
+ getHeaderCellStyleForZone(cell, rowIndex, zone) {
1216
+ let leftLeafIdx = cell._firstLeafFlatIndex
1217
+ if (cell.isGroup) {
1218
+ const end = leftLeafIdx + cell.colspan - 1
1219
+ for (let i = leftLeafIdx; i <= end; i++) {
1220
+ if (this.isColHidden(i)) continue
1221
+ const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')
1222
+ if (z === zone) { leftLeafIdx = i; break }
1223
+ }
1224
+ }
1225
+ const left = this.getZoneColLeft(leftLeafIdx, zone)
1226
+ let width = 0
1227
+ if (cell.isGroup) {
1228
+ const start = cell._firstLeafFlatIndex
1229
+ const end = start + cell.colspan - 1
1230
+ for (let i = start; i <= end; i++) {
1231
+ if (this.isColHidden(i)) continue
1232
+ const z = this.isColumnPinnedLeft(i) ? 'left' : (this.isColumnPinnedRight(i) ? 'right' : 'scroll')
1233
+ if (z !== zone) continue
1234
+ width += this.getColW(i)
1235
+ }
1236
+ } else {
1237
+ width = this.getColW(cell.column._flatIndex)
1238
+ }
1239
+ return {
1240
+ position: 'absolute',
1241
+ top: (HEADER_ROW_HEIGHT * rowIndex) + 'px',
1242
+ left: left + 'px',
1243
+ width: width + 'px',
1244
+ height: (HEADER_ROW_HEIGHT * cell.rowspan) + 'px',
1245
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1246
+ boxSizing: 'border-box',
1247
+ borderRight: '1px solid #ccc', borderBottom: '1px solid #ccc',
1248
+ background: '#f0f0f0', fontSize: '12px', fontWeight: 600,
1249
+ overflow: 'hidden', whiteSpace: 'nowrap', userSelect: 'none', cursor: 'pointer'
1250
+ }
1251
+ },
1252
+
1253
+ getRangeWidth(startCol, endCol) {
1254
+ let w = 0
1255
+ for (let i = startCol; i <= endCol && i < this.leafCols.length; i++) {
1256
+ if (!this.isColHidden(i)) w += this.getColW(i)
1257
+ }
1258
+ return w
1259
+ },
1260
+
1261
+ getRangeHeight(startRow, endRow) {
1262
+ let h = 0
1263
+ for (let i = startRow; i <= endRow && i < this.totalRows; i++) {
1264
+ if (!this.isRowHidden(i)) h += this.getRowH(i)
1265
+ }
1266
+ return h
1267
+ },
1268
+
1269
+ getCellValue(ri, ci) {
1270
+ const col = this.leafCols[ci]
1271
+ if (!col) return ''
1272
+ const row = this._dataRaw[ri]
1273
+ if (!row) return ''
1274
+ const v = row[col.key]
1275
+ return v !== undefined ? v : ''
1276
+ },
1277
+
1278
+ isEditing(ri, ci) {
1279
+ return this.editingCell && this.editingCell.row === ri && this.editingCell.col === ci
1280
+ },
1281
+
1282
+ isMergedAway(ri, ci) {
1283
+ const merge = findMergeAtCell(ri, ci, this.internalMerges)
1284
+ if (!merge) return false
1285
+ const nm = normalizeRange(merge)
1286
+ return !(nm.startRow === ri && nm.startCol === ci)
1287
+ },
1288
+
1289
+ getMergeSpan(ri, ci) {
1290
+ const merge = findMergeAtCell(ri, ci, this.internalMerges)
1291
+ if (!merge) return { rowspan: 1, colspan: 1 }
1292
+ const nm = normalizeRange(merge)
1293
+ return {
1294
+ rowspan: nm.endRow - nm.startRow + 1,
1295
+ colspan: nm.endCol - nm.startCol + 1
1296
+ }
1297
+ },
1298
+
1299
+ isSelected(ri, ci) {
1300
+ return isCellInAnyRange(ri, ci, this.selectionRanges)
1301
+ },
1302
+
1303
+ isActiveCell(ri, ci) {
1304
+ return this.activeCell.row === ri && this.activeCell.col === ci
1305
+ },
1306
+
1307
+ isCopyHighlighted(ri, ci) {
1308
+ if (!this.clipboardRange || this.clipboardMode !== 'copy') return false
1309
+ return isCellInRange(ri, ci, this.clipboardRange)
1310
+ },
1311
+
1312
+ // =========================================================
1313
+ // STYLES
1314
+ // =========================================================
1315
+ getRowStyle(ri) {
1316
+ const h = this.getRowH(ri)
1317
+ const key = h
1318
+ if (this._rowStyleCache[key]) return this._rowStyleCache[key]
1319
+ const style = { height: h + 'px', minHeight: h + 'px' }
1320
+ this._rowStyleCache[key] = style
1321
+ return style
1322
+ },
1323
+
1324
+ getRowHeaderStyle(ri) {
1325
+ const h = this.getRowH(ri)
1326
+ const key = h
1327
+ if (this._rowHeaderStyleCache[key]) return this._rowHeaderStyleCache[key]
1328
+ const style = { height: h + 'px' }
1329
+ this._rowHeaderStyleCache[key] = style
1330
+ return style
1331
+ },
1332
+
1333
+ getCellStyle(ri, ci) {
1334
+ const merge = findMergeAtCell(ri, ci, this.internalMerges)
1335
+ const nm = merge ? normalizeRange(merge) : null
1336
+ let w = this.getColW(ci)
1337
+ let h = this.getRowH(ri)
1338
+
1339
+ if (nm && nm.startRow === ri && nm.startCol === ci) {
1340
+ w = this.getRangeWidth(nm.startCol, nm.endCol)
1341
+ h = this.getRangeHeight(nm.startRow, nm.endRow)
1342
+ }
1343
+
1344
+ // Position cells with the same absolute-left arithmetic the header uses
1345
+ // (getZoneColLeft), instead of relying on flex layout to accumulate widths —
1346
+ // flex and a JS integer sum can drift apart by a few px over many columns,
1347
+ // desyncing the body's column borders from the header's.
1348
+ const zone = this.isColumnPinnedLeft(ci) ? 'left' : (this.isColumnPinnedRight(ci) ? 'right' : 'scroll')
1349
+ const left = this.getZoneColLeft(ci, zone)
1350
+
1351
+ // Cache by dimensions+position — same (w,h,left) → same object ref → Vue 2 skips DOM style writes
1352
+ const key = w + '_' + h + '_' + left
1353
+ if (this._cellStyleCache[key]) return this._cellStyleCache[key]
1354
+ const style = { position: 'absolute', left: left + 'px', top: '0px', width: w + 'px', minWidth: w + 'px', height: h + 'px' }
1355
+ this._cellStyleCache[key] = style
1356
+ return style
1357
+ },
1358
+
1359
+ getCellClasses(ri, ci) {
1360
+ const col = this.leafCols[ci]
1361
+ return {
1362
+ 'eg-cell-active': this.isActiveCell(ri, ci),
1363
+ 'eg-cell-selected': this.isSelected(ri, ci) && !this.isActiveCell(ri, ci),
1364
+ 'eg-cell-fill-preview': this.isInFillPreview(ri, ci),
1365
+ 'eg-cell-copy': this.isCopyHighlighted(ri, ci),
1366
+ 'eg-cell-merged-hidden': this.isMergedAway(ri, ci),
1367
+ 'eg-cell-select': !!(col && col.type === 'select'),
1368
+ 'eg-cell-checkbox': !!(col && col.type === 'checkbox'),
1369
+ 'eg-cell-readonly': !this.isCellEditable(ri, ci),
1370
+ 'eg-cell-invalid': !!(this.invalidCell && this.invalidCell.row === ri && this.invalidCell.col === ci)
1371
+ }
1372
+ },
1373
+
1374
+ isInFillPreview(ri, ci) {
1375
+ if (!this.fillDrag.active || !this.fillDrag.sourceRange) return false
1376
+ const src = this.fillDrag.sourceRange
1377
+ const targetRow = this.fillDrag.currentRow
1378
+ const targetCol = this.fillDrag.currentCol
1379
+ if (targetRow > src.endRow) {
1380
+ return ri > src.endRow && ri <= targetRow && ci >= src.startCol && ci <= src.endCol
1381
+ }
1382
+ if (targetCol > src.endCol) {
1383
+ return ci > src.endCol && ci <= targetCol && ri >= src.startRow && ri <= src.endRow
1384
+ }
1385
+ return false
1386
+ },
1387
+
1388
+ getHeaderCellStyle(cell, rowIndex, isFrozen) {
1389
+ const zone = isFrozen ? 'left' : 'scroll'
1390
+ let leftLeafIdx = cell._firstLeafFlatIndex
1391
+ if (cell.isGroup) {
1392
+ const end = leftLeafIdx + cell.colspan - 1
1393
+ for (let i = leftLeafIdx; i <= end; i++) {
1394
+ if (this.isColHidden(i)) continue
1395
+ if (isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)) { leftLeafIdx = i; break }
1396
+ }
1397
+ }
1398
+ const left = this.getZoneColLeft(leftLeafIdx, zone)
1399
+
1400
+ let width = 0
1401
+ if (cell.isGroup) {
1402
+ const start = cell._firstLeafFlatIndex
1403
+ const end = start + cell.colspan - 1
1404
+ for (let i = start; i <= end; i++) {
1405
+ if (this.isColHidden(i)) continue
1406
+ const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)
1407
+ if (!inZone) continue
1408
+ width += this.getColW(i)
1409
+ }
1410
+ } else {
1411
+ width = this.getColW(cell.column._flatIndex)
1412
+ }
1413
+
1414
+ return {
1415
+ position: 'absolute',
1416
+ top: (HEADER_ROW_HEIGHT * rowIndex) + 'px',
1417
+ left: left + 'px',
1418
+ width: width + 'px',
1419
+ height: (HEADER_ROW_HEIGHT * cell.rowspan) + 'px',
1420
+ display: 'flex',
1421
+ alignItems: 'center',
1422
+ justifyContent: 'center',
1423
+ boxSizing: 'border-box',
1424
+ borderRight: '1px solid #ccc',
1425
+ borderBottom: '1px solid #ccc',
1426
+ background: '#f0f0f0',
1427
+ fontSize: '12px',
1428
+ fontWeight: 600,
1429
+ overflow: 'hidden',
1430
+ whiteSpace: 'nowrap',
1431
+ userSelect: 'none',
1432
+ cursor: 'pointer'
1433
+ }
1434
+ },
1435
+
1436
+ isCellVisible(cell, rowIndex, isFrozen) {
1437
+ if (cell.isGroup) {
1438
+ const start = cell._firstLeafFlatIndex
1439
+ const end = start + cell.colspan - 1
1440
+ for (let i = start; i <= end; i++) {
1441
+ if (this.isColHidden(i)) continue
1442
+ const inZone = isFrozen ? this.isColumnPinnedLeft(i) : this.isColumnScrollable(i)
1443
+ if (inZone) return true
1444
+ }
1445
+ return false
1446
+ }
1447
+ const ci = cell.column._flatIndex
1448
+ if (this.isColHidden(ci)) return false
1449
+ return isFrozen ? this.isColumnPinnedLeft(ci) : this.isColumnScrollable(ci)
1450
+ },
1451
+
1452
+ getRowGroupBtnStyle(group) {
1453
+ const top = getRowTop(group.startIndex, ROW_HEIGHT, this.rowHeights) - this.scrollTop
1454
+ return {
1455
+ position: 'absolute',
1456
+ top: top + 'px',
1457
+ left: '2px',
1458
+ width: '16px',
1459
+ height: '16px',
1460
+ lineHeight: '16px',
1461
+ textAlign: 'center',
1462
+ fontSize: '10px',
1463
+ cursor: 'pointer',
1464
+ background: '#e8eaf6',
1465
+ border: '1px solid #9fa8da',
1466
+ borderRadius: '2px',
1467
+ zIndex: 5
1468
+ }
1469
+ },
1470
+
1471
+ getColGroupBtnStyle(group) {
1472
+ const left = this.getColLeft(group.startIndex) - this.scrollLeft
1473
+ return {
1474
+ position: 'absolute',
1475
+ left: left + 'px',
1476
+ top: '2px',
1477
+ width: '16px',
1478
+ height: '16px',
1479
+ lineHeight: '16px',
1480
+ textAlign: 'center',
1481
+ fontSize: '10px',
1482
+ cursor: 'pointer',
1483
+ background: '#e8eaf6',
1484
+ border: '1px solid #9fa8da',
1485
+ borderRadius: '2px'
1486
+ }
1487
+ },
1488
+
1489
+ // =========================================================
1490
+ // CELL SELECTION (3.1)
1491
+ // =========================================================
1492
+ selectCell(row, col, addToSelection = false) {
1493
+ if (row < 0 || row >= this.totalRows || col < 0 || col >= this.leafCols.length) return
1494
+ this.activeCell = { row, col }
1495
+ if (addToSelection) {
1496
+ this.selectionRanges.push(singleCellRange(row, col))
1497
+ } else {
1498
+ this.selectionRanges = [singleCellRange(row, col)]
1499
+ }
1500
+ this.emitSelectionChange()
1501
+ },
1502
+
1503
+ selectRange(startRow, startCol, endRow, endCol, replace = true) {
1504
+ const range = { startRow, startCol, endRow, endCol }
1505
+ if (replace) {
1506
+ this.selectionRanges = [range]
1507
+ } else {
1508
+ this.selectionRanges.push(range)
1509
+ }
1510
+ this.emitSelectionChange()
1511
+ },
1512
+
1513
+ selectRow(ri) {
1514
+ this.activeCell = { row: ri, col: 0 }
1515
+ this.selectionRanges = [{ startRow: ri, endRow: ri, startCol: 0, endCol: this.leafCols.length - 1 }]
1516
+ this.emitSelectionChange()
1517
+ },
1518
+
1519
+ selectColumn(ci) {
1520
+ this.activeCell = { row: 0, col: ci }
1521
+ this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]
1522
+ this.emitSelectionChange()
1523
+ },
1524
+
1525
+ selectAll() {
1526
+ this.activeCell = { row: 0, col: 0 }
1527
+ this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: 0, endCol: this.leafCols.length - 1 }]
1528
+ this.emitSelectionChange()
1529
+ },
1530
+
1531
+ emitSelectionChange() {
1532
+ this.$emit('selection-change', {
1533
+ activeCell: { ...this.activeCell },
1534
+ ranges: this.selectionRanges.map(r => normalizeRange(r))
1535
+ })
1536
+ },
1537
+
1538
+ onCellMouseDown(e, ri, ci) {
1539
+ this.invalidCell = null
1540
+ if (this.editingCell) {
1541
+ this.commitEdit()
1542
+ }
1543
+ this.hideContextMenu()
1544
+ this.$refs.wrapper && this.$refs.wrapper.focus()
1545
+
1546
+ if (e.ctrlKey || e.metaKey) {
1547
+ if (this.selectDropdown) this.closeSelectDropdown()
1548
+ // Ctrl+click: start new independent range
1549
+ this.activeCell = { row: ri, col: ci }
1550
+ this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }
1551
+ this.selectionRanges.push(this.mouseDownRange)
1552
+ this.isCtrlDrag = true
1553
+ } else if (e.shiftKey && this.selectionRanges.length > 0) {
1554
+ if (this.selectDropdown) this.closeSelectDropdown()
1555
+ // Shift+click: extend primary range to this cell
1556
+ this.selectionRanges = [buildRange(this.activeCell, { row: ri, col: ci })]
1557
+ } else {
1558
+ const wasActive = this.activeCell.row === ri && this.activeCell.col === ci
1559
+ this.activeCell = { row: ri, col: ci }
1560
+ this.mouseDownRange = { startRow: ri, startCol: ci, endRow: ri, endCol: ci }
1561
+ this.selectionRanges = [this.mouseDownRange]
1562
+ this.isCtrlDrag = false
1563
+
1564
+ // Open select dropdown on click if cell was already active (2nd click)
1565
+ const col = this.leafCols[ci]
1566
+ if (wasActive && col && col.type === 'select' && this.isCellEditable(ri, ci)) {
1567
+ this.openSelectDropdown(ri, ci, e)
1568
+ e.preventDefault()
1569
+ this.isMouseDown = true
1570
+ this.emitSelectionChange()
1571
+ return
1572
+ }
1573
+ if (this.selectDropdown) this.closeSelectDropdown()
1574
+ }
1575
+
1576
+ this.isMouseDown = true
1577
+ this.emitSelectionChange()
1578
+ e.preventDefault()
1579
+ },
1580
+
1581
+ onCellMouseOver(e, ri, ci) {
1582
+ if (!this.isMouseDown) return
1583
+ if (this.fillDrag.active) return
1584
+ if (this.dragMove.active) {
1585
+ this.dragMove.targetRow = ri
1586
+ this.dragMove.targetCol = ci
1587
+ return
1588
+ }
1589
+
1590
+ // Extend current drag range
1591
+ if (this.mouseDownRange) {
1592
+ this.mouseDownRange.endRow = ri
1593
+ this.mouseDownRange.endCol = ci
1594
+ this.emitSelectionChange()
1595
+ }
1596
+ },
1597
+
1598
+ onCellMouseUp() {
1599
+ this.isMouseDown = false
1600
+ },
1601
+
1602
+ onCellDblClick(ri, ci) {
1603
+ const col = this.leafCols[ci]
1604
+ if (!this.isCellEditable(ri, ci)) return
1605
+ if (col && col.type === 'select') {
1606
+ this.openSelectDropdownForActive()
1607
+ return
1608
+ }
1609
+ if (col && col.type === 'checkbox') return
1610
+ this.startEditing(ri, ci, null, true)
1611
+ },
1612
+
1613
+ onRowHeaderMouseDown(e, ri) {
1614
+ const endCol = this.leafCols.length - 1
1615
+ const fullRow = { startRow: ri, endRow: ri, startCol: 0, endCol }
1616
+
1617
+ if (e.ctrlKey || e.metaKey) {
1618
+ if (e.shiftKey && this.selectionRanges.length > 0) {
1619
+ // Ctrl+Shift: active qatordan ri gacha yangi range qo'sh
1620
+ const sr = Math.min(this.activeCell.row, ri)
1621
+ const er = Math.max(this.activeCell.row, ri)
1622
+ this.selectionRanges = [...this.selectionRanges, { startRow: sr, endRow: er, startCol: 0, endCol }]
1623
+ } else {
1624
+ // Ctrl+click: qatorni toggle qil
1625
+ const idx = this.selectionRanges.findIndex(r =>
1626
+ r.startRow === ri && r.endRow === ri && r.startCol === 0 && r.endCol === endCol
1627
+ )
1628
+ if (idx >= 0) {
1629
+ const next = this.selectionRanges.filter((_, i) => i !== idx)
1630
+ this.selectionRanges = next.length ? next : [fullRow]
1631
+ } else {
1632
+ this.activeCell = { row: ri, col: 0 }
1633
+ this.selectionRanges = [...this.selectionRanges, fullRow]
1634
+ }
1635
+ }
1636
+ } else if (e.shiftKey && this.selectionRanges.length > 0) {
1637
+ const sr = Math.min(this.activeCell.row, ri)
1638
+ const er = Math.max(this.activeCell.row, ri)
1639
+ this.selectionRanges = [{ startRow: sr, endRow: er, startCol: 0, endCol }]
1640
+ } else {
1641
+ this.activeCell = { row: ri, col: 0 }
1642
+ this.selectionRanges = [fullRow]
1643
+ }
1644
+
1645
+ this.mouseDownRange = this.selectionRanges[this.selectionRanges.length - 1]
1646
+ this.isMouseDown = true
1647
+ this.emitSelectionChange()
1648
+ e.preventDefault()
1649
+ },
1650
+
1651
+ onColHeaderMouseDown(e, cell) {
1652
+ if (cell.isGroup) return
1653
+ const ci = cell.column._flatIndex
1654
+ if (e.shiftKey && this.selectionRanges.length > 0) {
1655
+ const sc = Math.min(this.activeCell.col, ci)
1656
+ const ec = Math.max(this.activeCell.col, ci)
1657
+ this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: sc, endCol: ec }]
1658
+ } else {
1659
+ this.activeCell = { row: 0, col: ci }
1660
+ this.selectionRanges = [{ startRow: 0, endRow: this.totalRows - 1, startCol: ci, endCol: ci }]
1661
+ }
1662
+ this.isMouseDown = true
1663
+ this.mouseDownRange = this.selectionRanges[0]
1664
+ this.emitSelectionChange()
1665
+ e.preventDefault()
1666
+ },
1667
+
1668
+ onColHeaderDblClick(e, cell) {
1669
+ if (cell.isGroup) return
1670
+ this.autoFitColumn(cell.column._flatIndex)
1671
+ },
1672
+
1673
+ onColHeaderContextMenu(e, cell) {
1674
+ if (!cell.isGroup) {
1675
+ this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row: -1, col: cell.column._flatIndex }
1676
+ }
1677
+ },
1678
+
1679
+ onRowContextMenu(e, ri) {
1680
+ this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row: ri, col: -1 }
1681
+ },
1682
+
1683
+ onCellContextMenu(e, ri, ci) {
1684
+ if (!this.isSelected(ri, ci)) {
1685
+ this.selectCell(ri, ci)
1686
+ }
1687
+ this.contextMenu = { visible: true, x: e.clientX, y: e.clientY, row: ri, col: ci }
1688
+ },
1689
+
1690
+ hideContextMenu() {
1691
+ this.contextMenu.visible = false
1692
+ },
1693
+
1694
+ // =========================================================
1695
+ // KEYBOARD NAVIGATION (3.2)
1696
+ // =========================================================
1697
+ onKeyDown(e) {
1698
+ // Handle open select dropdown keyboard navigation
1699
+ if (this.selectDropdown) {
1700
+ const key = e.key
1701
+ if (key === 'Escape') {
1702
+ e.preventDefault()
1703
+ this.closeSelectDropdown()
1704
+ return
1705
+ }
1706
+ if (key === 'Enter' || key === ' ') {
1707
+ e.preventDefault()
1708
+ const h = this.selectDropdown.highlight
1709
+ if (h >= 0 && this.selectDropdown.options[h]) {
1710
+ this.commitSelectValue(this.selectDropdown.options[h].value)
1711
+ } else {
1712
+ this.closeSelectDropdown()
1713
+ }
1714
+ return
1715
+ }
1716
+ if (key === 'ArrowDown') {
1717
+ e.preventDefault()
1718
+ this.selectDropdown.highlight = Math.min(this.selectDropdown.highlight + 1, this.selectDropdown.options.length - 1)
1719
+ return
1720
+ }
1721
+ if (key === 'ArrowUp') {
1722
+ e.preventDefault()
1723
+ this.selectDropdown.highlight = Math.max(this.selectDropdown.highlight - 1, 0)
1724
+ return
1725
+ }
1726
+ return // block other keys while dropdown open
1727
+ }
1728
+
1729
+ if (this.editingCell) return // handled by onEditKeyDown
1730
+ const key = e.key
1731
+
1732
+ if ((e.ctrlKey || e.metaKey) && key === 'z' && !e.shiftKey) {
1733
+ e.preventDefault()
1734
+ this.undo()
1735
+ return
1736
+ }
1737
+ if ((e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey))) {
1738
+ e.preventDefault()
1739
+ this.redo()
1740
+ return
1741
+ }
1742
+
1743
+ if (key === 'a' && (e.ctrlKey || e.metaKey)) {
1744
+ e.preventDefault()
1745
+ this.selectAll()
1746
+ return
1747
+ }
1748
+
1749
+ if (key === 'c' && (e.ctrlKey || e.metaKey)) {
1750
+ e.preventDefault()
1751
+ this.copySelection()
1752
+ return
1753
+ }
1754
+ if (key === 'x' && (e.ctrlKey || e.metaKey)) {
1755
+ e.preventDefault()
1756
+ this.cutSelection()
1757
+ return
1758
+ }
1759
+ if (key === 'v' && (e.ctrlKey || e.metaKey)) {
1760
+ e.preventDefault()
1761
+ this.pasteAt(this.activeCell.row, this.activeCell.col)
1762
+ return
1763
+ }
1764
+
1765
+ if (key === 'Delete' || key === 'Backspace') {
1766
+ e.preventDefault()
1767
+ this.clearSelection()
1768
+ return
1769
+ }
1770
+
1771
+ if (key === 'F2') {
1772
+ e.preventDefault()
1773
+ const activeCol = this.leafCols[this.activeCell.col]
1774
+ if (activeCol && activeCol.type === 'select') {
1775
+ this.openSelectDropdownForActive()
1776
+ } else if (activeCol && activeCol.type === 'checkbox') {
1777
+ this.toggleCheckbox(this.activeCell.row, this.activeCell.col)
1778
+ } else {
1779
+ this.startEditing(this.activeCell.row, this.activeCell.col, null, true)
1780
+ }
1781
+ return
1782
+ }
1783
+
1784
+ if (key === ' ') {
1785
+ const activeCol = this.leafCols[this.activeCell.col]
1786
+ if (activeCol && activeCol.type === 'checkbox') {
1787
+ e.preventDefault()
1788
+ this.toggleCheckbox(this.activeCell.row, this.activeCell.col)
1789
+ return
1790
+ }
1791
+ }
1792
+
1793
+ if (key === 'Escape') {
1794
+ this.clipboardRange = null
1795
+ this.clipboardMatrix = null
1796
+ this.clipboardMode = null
1797
+ return
1798
+ }
1799
+
1800
+ if (key === 'Tab') {
1801
+ e.preventDefault()
1802
+ this.moveActive(0, e.shiftKey ? -1 : 1, e)
1803
+ return
1804
+ }
1805
+ if (key === 'Enter') {
1806
+ e.preventDefault()
1807
+ this.moveActive(e.shiftKey ? -1 : 1, 0, e)
1808
+ return
1809
+ }
1810
+
1811
+ const arrows = { ArrowUp: [-1,0], ArrowDown: [1,0], ArrowLeft: [0,-1], ArrowRight: [0,1] }
1812
+ if (arrows[key]) {
1813
+ e.preventDefault()
1814
+ const [dr, dc] = arrows[key]
1815
+ if (e.shiftKey) {
1816
+ // Extend selection
1817
+ const pr = this.primaryRange || singleCellRange(this.activeCell.row, this.activeCell.col)
1818
+ const nm = normalizeRange(pr)
1819
+ // Determine which edge to extend
1820
+ if (dr === -1) pr.startRow = Math.max(0, nm.startRow - 1)
1821
+ if (dr === 1) pr.endRow = Math.min(this.totalRows - 1, nm.endRow + 1)
1822
+ if (dc === -1) pr.startCol = Math.max(0, nm.startCol - 1)
1823
+ if (dc === 1) pr.endCol = Math.min(this.leafCols.length - 1, nm.endCol + 1)
1824
+ this.selectionRanges = [pr]
1825
+ this.emitSelectionChange()
1826
+ } else {
1827
+ this.moveActive(dr, dc, e)
1828
+ }
1829
+ return
1830
+ }
1831
+
1832
+ if (key === 'Home') {
1833
+ e.preventDefault()
1834
+ if (e.ctrlKey) {
1835
+ this.selectCell(0, 0)
1836
+ } else {
1837
+ this.selectCell(this.activeCell.row, 0)
1838
+ }
1839
+ return
1840
+ }
1841
+ if (key === 'End') {
1842
+ e.preventDefault()
1843
+ if (e.ctrlKey) {
1844
+ this.selectCell(this.totalRows - 1, this.leafCols.length - 1)
1845
+ } else {
1846
+ this.selectCell(this.activeCell.row, this.leafCols.length - 1)
1847
+ }
1848
+ return
1849
+ }
1850
+ if (key === 'PageUp') {
1851
+ e.preventDefault()
1852
+ const visibleRows = Math.floor((this.height - HEADER_ROW_HEIGHT * this.headerDepth) / ROW_HEIGHT)
1853
+ this.moveActive(-visibleRows, 0, e)
1854
+ return
1855
+ }
1856
+ if (key === 'PageDown') {
1857
+ e.preventDefault()
1858
+ const visibleRows = Math.floor((this.height - HEADER_ROW_HEIGHT * this.headerDepth) / ROW_HEIGHT)
1859
+ this.moveActive(visibleRows, 0, e)
1860
+ return
1861
+ }
1862
+ },
1863
+
1864
+ onKeyPress(e) {
1865
+ if (this.editingCell) return
1866
+ if (this.selectDropdown) return
1867
+ if (e.ctrlKey || e.metaKey || e.altKey) return
1868
+ if (e.key && e.key.length === 1) {
1869
+ const activeCol = this.leafCols[this.activeCell.col]
1870
+ if (activeCol && activeCol.type === 'select') {
1871
+ e.preventDefault()
1872
+ this.openSelectDropdownForActive()
1873
+ return
1874
+ }
1875
+ if (activeCol && activeCol.type === 'checkbox') {
1876
+ e.preventDefault()
1877
+ return
1878
+ }
1879
+ this.startEditing(this.activeCell.row, this.activeCell.col, e.key, false)
1880
+ }
1881
+ },
1882
+
1883
+ onGlobalKeyDown(e) {
1884
+ // Handled by focused wrapper
1885
+ },
1886
+
1887
+ moveActive(dr, dc, e) {
1888
+ let row = this.activeCell.row + dr
1889
+ let col = this.activeCell.col + dc
1890
+
1891
+ // Tab: wrap around rows
1892
+ if (e && e.key === 'Tab') {
1893
+ if (col >= this.leafCols.length) { col = 0; row++ }
1894
+ if (col < 0) { col = this.leafCols.length - 1; row-- }
1895
+ }
1896
+
1897
+ row = Math.max(0, Math.min(this.totalRows - 1, row))
1898
+ col = Math.max(0, Math.min(this.leafCols.length - 1, col))
1899
+
1900
+ // Skip hidden cols
1901
+ while (this.isColHidden(col) && col > 0 && col < this.leafCols.length - 1) {
1902
+ col += dc >= 0 ? 1 : -1
1903
+ }
1904
+ // Skip hidden rows
1905
+ while (this.isRowHidden(row) && row > 0 && row < this.totalRows - 1) {
1906
+ row += dr >= 0 ? 1 : -1
1907
+ }
1908
+
1909
+ // Handle merge navigation
1910
+ const resolved = resolveMergeAnchor(row, col, this.internalMerges)
1911
+ row = resolved.row
1912
+ col = resolved.col
1913
+
1914
+ this.activeCell = { row, col }
1915
+ this.selectionRanges = [singleCellRange(row, col)]
1916
+ this.emitSelectionChange()
1917
+ this.scrollToCell(row, col)
1918
+ },
1919
+
1920
+ scrollToCell(ri, ci) {
1921
+ const scrollBody = this.$refs.scrollBody
1922
+ if (!scrollBody) return
1923
+ const rowTop = getRowTop(ri, ROW_HEIGHT, this.rowHeights)
1924
+ const rowBottom = rowTop + this.getRowH(ri)
1925
+ const colLeft = this.getColLeft(ci) - this.getFrozenWidth()
1926
+ const colRight = colLeft + this.getColW(ci)
1927
+
1928
+ if (rowTop < scrollBody.scrollTop) scrollBody.scrollTop = rowTop
1929
+ if (rowBottom > scrollBody.scrollTop + scrollBody.clientHeight) {
1930
+ scrollBody.scrollTop = rowBottom - scrollBody.clientHeight
1931
+ }
1932
+ if (colLeft < scrollBody.scrollLeft) scrollBody.scrollLeft = colLeft
1933
+ if (colRight > scrollBody.scrollLeft + scrollBody.clientWidth) {
1934
+ scrollBody.scrollLeft = colRight - scrollBody.clientWidth
1935
+ }
1936
+ },
1937
+
1938
+ // =========================================================
1939
+ // EDITING (used by 3.1 + 3.2)
1940
+ // =========================================================
1941
+ startEditing(ri, ci, initialChar = null, keepValue = false) {
1942
+ const col = this.leafCols[ci]
1943
+ if (!col || !this.isCellEditable(ri, ci)) return
1944
+ if (ri < 0 || ri >= this.totalRows) return
1945
+ if (col.type === 'select') { this.openSelectDropdownForActive(); return }
1946
+ if (col.type === 'checkbox') return
1947
+
1948
+ this.invalidCell = null
1949
+ this.editingCell = { row: ri, col: ci }
1950
+ if (initialChar !== null) {
1951
+ this.editValue = initialChar
1952
+ } else if (keepValue) {
1953
+ this.editValue = String(this.getCellValue(ri, ci) || '')
1954
+ } else {
1955
+ this.editValue = ''
1956
+ }
1957
+ this.editOriginalValue = String(this.getCellValue(ri, ci) || '')
1958
+
1959
+ this.$nextTick(() => {
1960
+ const inputs = this.$refs.editInput
1961
+ if (inputs) {
1962
+ const input = Array.isArray(inputs) ? inputs[0] : inputs
1963
+ if (input) {
1964
+ input.focus()
1965
+ if (keepValue) {
1966
+ input.setSelectionRange(input.value.length, input.value.length)
1967
+ }
1968
+ }
1969
+ }
1970
+ })
1971
+ },
1972
+
1973
+ commitEdit() {
1974
+ if (!this.editingCell) return
1975
+ const { row, col } = this.editingCell
1976
+ const colDef = this.leafCols[col]
1977
+ if (!colDef) { this.editingCell = null; return }
1978
+
1979
+ if (!this.isCellValueValid(row, col, this.editValue)) {
1980
+ this._showInvalidCell(row, col, this.editValue)
1981
+ this.editingCell = null
1982
+ this.$refs.wrapper && this.$refs.wrapper.focus()
1983
+ return
1984
+ }
1985
+
1986
+ this.invalidCell = null
1987
+ const oldData = this.data
1988
+ const newData = oldData.slice()
1989
+ newData[row] = { ...oldData[row], [colDef.key]: this.editValue }
1990
+ this._emitDataChange(oldData, newData)
1991
+ this.editingCell = null
1992
+ this.$refs.wrapper && this.$refs.wrapper.focus()
1993
+ },
1994
+
1995
+ cancelEdit() {
1996
+ this.editingCell = null
1997
+ this.$refs.wrapper && this.$refs.wrapper.focus()
1998
+ },
1999
+
2000
+ // =========================================================
2001
+ // CHECKBOX CELLS
2002
+ // =========================================================
2003
+
2004
+ isCheckboxChecked(ri, ci) {
2005
+ const v = this.getCellValue(ri, ci)
2006
+ if (v === true || v === 1) return true
2007
+ if (typeof v === 'string') return ['true', '1'].includes(v.trim().toLowerCase())
2008
+ return false
2009
+ },
2010
+
2011
+ setCheckboxValue(ri, ci, value) {
2012
+ const col = this.leafCols[ci]
2013
+ if (!col || !this.isCellEditable(ri, ci)) return
2014
+ if (!this.isCellValueValid(ri, ci, value)) {
2015
+ this._showInvalidCell(ri, ci, value)
2016
+ return
2017
+ }
2018
+ this.invalidCell = null
2019
+ const oldData = this.data
2020
+ const newData = oldData.slice()
2021
+ newData[ri] = { ...oldData[ri], [col.key]: value }
2022
+ this._emitDataChange(oldData, newData)
2023
+ },
2024
+
2025
+ toggleCheckbox(ri, ci) {
2026
+ this.setCheckboxValue(ri, ci, !this.isCheckboxChecked(ri, ci))
2027
+ },
2028
+
2029
+ onCheckboxChange(e, ri, ci) {
2030
+ this.setCheckboxValue(ri, ci, e.target.checked)
2031
+ },
2032
+
2033
+ // =========================================================
2034
+ // CELL EDITABILITY (3.17)
2035
+ // =========================================================
2036
+
2037
+ isCellEditable(ri, ci) {
2038
+ const col = this.leafCols[ci]
2039
+ if (!col) return false
2040
+ const row = this._dataRaw[ri] || {}
2041
+ if (typeof this.cellEditable === 'function') return !!this.cellEditable(row, ri, col, ci)
2042
+ if (typeof col.editable === 'function') return !!col.editable(row, ri)
2043
+ if (col.editable === false) return false
2044
+ return true
2045
+ },
2046
+
2047
+ // =========================================================
2048
+ // CELL VALUE VALIDATION (cellValidator)
2049
+ // =========================================================
2050
+
2051
+ isCellValueValid(ri, ci, value) {
2052
+ if (typeof this.cellValidator !== 'function') return true
2053
+ const col = this.leafCols[ci]
2054
+ if (!col) return true
2055
+ const row = this._dataRaw[ri] || {}
2056
+ return !!this.cellValidator(value, row, ri, col, ci)
2057
+ },
2058
+
2059
+ _resolveValidationMessage(ri, ci, value) {
2060
+ const col = this.leafCols[ci]
2061
+ const row = this._dataRaw[ri] || {}
2062
+ if (typeof this.cellValidationMessage === 'function') return this.cellValidationMessage(value, row, ri, col, ci)
2063
+ if (typeof this.cellValidationMessage === 'string' && this.cellValidationMessage) return this.cellValidationMessage
2064
+ return this.t.invalidValue
2065
+ },
2066
+
2067
+ // Find the on-screen rect of the cell currently being committed — the edit
2068
+ // input if still mounted (commitEdit, before editingCell is cleared), else
2069
+ // the active cell element (checkbox/select commits, which never enter edit mode).
2070
+ _invalidCellRect() {
2071
+ const inputs = this.$refs.editInput
2072
+ const input = Array.isArray(inputs) ? inputs[0] : inputs
2073
+ if (input) return input.getBoundingClientRect()
2074
+ const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null
2075
+ return el ? el.getBoundingClientRect() : null
2076
+ },
2077
+
2078
+ _showInvalidCell(ri, ci, value) {
2079
+ const message = this._resolveValidationMessage(ri, ci, value)
2080
+ this.invalidCell = { row: ri, col: ci, message, rect: this._invalidCellRect() }
2081
+ this.$emit('cell-validation-error', { row: ri, col: ci, value, message })
2082
+ },
2083
+
2084
+ // Resolve+emit messages for a batch of rejected { row, col, value } entries
2085
+ // from a bulk write (paste / fill / clear). blocked=true means the whole
2086
+ // operation was aborted (strictBulkValidation); false means these cells
2087
+ // were skipped but the rest of the write still applied.
2088
+ _emitBulkValidationError(skipped, blocked) {
2089
+ const cells = skipped.map(c => ({
2090
+ row: c.row, col: c.col, value: c.value,
2091
+ message: this._resolveValidationMessage(c.row, c.col, c.value)
2092
+ }))
2093
+ this.$emit('bulk-validation-error', { blocked, cells })
2094
+ },
2095
+
2096
+ // =========================================================
2097
+ // SELECT DROPDOWN (3.16)
2098
+ // =========================================================
2099
+
2100
+ colOptions(ci) {
2101
+ const col = this.leafCols[ci]
2102
+ if (!col || !Array.isArray(col.options)) return []
2103
+ return col.options.map(o =>
2104
+ typeof o === 'string' ? { value: o, label: o } : { value: o.value, label: o.label != null ? o.label : String(o.value), color: o.color }
2105
+ )
2106
+ },
2107
+
2108
+ getSelectLabel(ci, value) {
2109
+ const opts = this.colOptions(ci)
2110
+ if (!opts.length) return value == null ? '' : String(value)
2111
+ const v = value == null ? '' : String(value)
2112
+ const found = opts.find(o => String(o.value) === v)
2113
+ return found ? found.label : v
2114
+ },
2115
+
2116
+ openSelectDropdown(ri, ci, e) {
2117
+ const col = this.leafCols[ci]
2118
+ if (!col || !Array.isArray(col.options)) return
2119
+ const el = e.currentTarget
2120
+ const rect = el.getBoundingClientRect()
2121
+ const currentValue = String(this.getCellValue(ri, ci) != null ? this.getCellValue(ri, ci) : '')
2122
+ const opts = this.colOptions(ci)
2123
+ this.selectDropdown = {
2124
+ row: ri, col: ci,
2125
+ options: opts,
2126
+ currentValue,
2127
+ highlight: opts.findIndex(o => String(o.value) === currentValue),
2128
+ rect: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }
2129
+ }
2130
+ },
2131
+
2132
+ openSelectDropdownForActive() {
2133
+ const { row, col } = this.activeCell
2134
+ const colDef = this.leafCols[col]
2135
+ if (!colDef || !Array.isArray(colDef.options)) return
2136
+ this.$nextTick(() => {
2137
+ const el = this.$el ? this.$el.querySelector('.eg-cell-active') : null
2138
+ if (!el) return
2139
+ const rect = el.getBoundingClientRect()
2140
+ const currentValue = String(this.getCellValue(row, col) != null ? this.getCellValue(row, col) : '')
2141
+ const opts = this.colOptions(col)
2142
+ this.selectDropdown = {
2143
+ row, col,
2144
+ options: opts,
2145
+ currentValue,
2146
+ highlight: opts.findIndex(o => String(o.value) === currentValue),
2147
+ rect: { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }
2148
+ }
2149
+ })
2150
+ },
2151
+
2152
+ commitSelectValue(value) {
2153
+ if (!this.selectDropdown) return
2154
+ const { row, col } = this.selectDropdown
2155
+ const colDef = this.leafCols[col]
2156
+ if (colDef) {
2157
+ if (!this.isCellValueValid(row, col, value)) {
2158
+ this._showInvalidCell(row, col, value)
2159
+ this.closeSelectDropdown()
2160
+ this.$refs.wrapper && this.$refs.wrapper.focus()
2161
+ return
2162
+ }
2163
+ this.invalidCell = null
2164
+ const oldData = this.data
2165
+ const newData = oldData.slice()
2166
+ newData[row] = { ...oldData[row], [colDef.key]: value }
2167
+ this._emitDataChange(oldData, newData)
2168
+ }
2169
+ this.closeSelectDropdown()
2170
+ this.$refs.wrapper && this.$refs.wrapper.focus()
2171
+ },
2172
+
2173
+ closeSelectDropdown() {
2174
+ this.selectDropdown = null
2175
+ },
2176
+
2177
+ _onDocMouseDownForDropdown(e) {
2178
+ if (!this.selectDropdown) return
2179
+ const dd = this.$el ? this.$el.querySelector('.eg-select-dropdown') : null
2180
+ if (dd && dd.contains(e.target)) return
2181
+ // Click outside dropdown — close it
2182
+ this.closeSelectDropdown()
2183
+ },
2184
+
2185
+ onEditKeyDown(e) {
2186
+ if (e.key === 'Escape') {
2187
+ e.preventDefault()
2188
+ this.cancelEdit()
2189
+ } else if (e.key === 'Enter' && !e.shiftKey) {
2190
+ e.preventDefault()
2191
+ this.commitEdit()
2192
+ this.moveActive(1, 0, e)
2193
+ } else if (e.key === 'Tab') {
2194
+ e.preventDefault()
2195
+ this.commitEdit()
2196
+ this.moveActive(0, e.shiftKey ? -1 : 1, e)
2197
+ }
2198
+ },
2199
+
2200
+ clearSelection() {
2201
+ if (!this.selectionRanges.length) return
2202
+ const oldData = this.data
2203
+ const newData = oldData.slice()
2204
+ let hasChanges = false
2205
+ const skipped = []
2206
+ for (const range of this.selectionRanges) {
2207
+ const r = {
2208
+ startRow: Math.min(range.startRow, range.endRow),
2209
+ endRow: Math.max(range.startRow, range.endRow),
2210
+ startCol: Math.min(range.startCol, range.endCol),
2211
+ endCol: Math.max(range.startCol, range.endCol)
2212
+ }
2213
+ for (let ri = r.startRow; ri <= r.endRow && ri < newData.length; ri++) {
2214
+ let rowUpdated = null
2215
+ for (let ci = r.startCol; ci <= r.endCol; ci++) {
2216
+ const col = this.leafCols[ci]
2217
+ if (col && this.isCellEditable(ri, ci)) {
2218
+ if (!this.isCellValueValid(ri, ci, '')) { skipped.push({ row: ri, col: ci, value: '' }); continue }
2219
+ if (!rowUpdated) rowUpdated = { ...newData[ri] }
2220
+ rowUpdated[col.key] = ''
2221
+ hasChanges = true
2222
+ }
2223
+ }
2224
+ if (rowUpdated) newData[ri] = rowUpdated
2225
+ }
2226
+ }
2227
+ if (this.strictBulkValidation && skipped.length > 0) {
2228
+ this._emitBulkValidationError(skipped, true)
2229
+ return
2230
+ }
2231
+ if (hasChanges) this._emitDataChange(oldData, newData)
2232
+ if (skipped.length > 0) this._emitBulkValidationError(skipped, false)
2233
+ },
2234
+
2235
+ // =========================================================
2236
+ // FILL HANDLE (3.3)
2237
+ // =========================================================
2238
+ startFillDrag(e) {
2239
+ const pr = this.primaryRange
2240
+ if (!pr) return
2241
+ this.fillDrag.active = true
2242
+ this.fillDrag.sourceRange = normalizeRange(pr)
2243
+ this.fillDrag.currentRow = pr.endRow
2244
+ this.fillDrag.currentCol = pr.endCol
2245
+ e.preventDefault()
2246
+ },
2247
+
2248
+ onGlobalMouseMove(e) {
2249
+ if (this.resizing.active) {
2250
+ this.handleResizeMove(e)
2251
+ return
2252
+ }
2253
+ if (this.fillDrag.active) {
2254
+ this.handleFillDragMove(e)
2255
+ return
2256
+ }
2257
+ if (this.dragMove.active) {
2258
+ this.handleDragMoveMove(e)
2259
+ return
2260
+ }
2261
+ },
2262
+
2263
+ onGlobalMouseUp(e) {
2264
+ if (this.resizing.active) {
2265
+ this.commitResize()
2266
+ return
2267
+ }
2268
+ if (this.fillDrag.active) {
2269
+ this.commitFillDrag()
2270
+ return
2271
+ }
2272
+ if (this.dragMove.active) {
2273
+ this.commitDragMove(e)
2274
+ return
2275
+ }
2276
+ this.isMouseDown = false
2277
+ },
2278
+
2279
+ handleFillDragMove(e) {
2280
+ const scrollBody = this.$refs.scrollBody
2281
+ if (!scrollBody) return
2282
+ const rect = scrollBody.getBoundingClientRect()
2283
+ const relY = e.clientY - rect.top + scrollBody.scrollTop
2284
+ const relX = e.clientX - rect.left + scrollBody.scrollLeft
2285
+
2286
+ // Binary search on cumulativeHeights — O(log n) instead of O(n)
2287
+ const cum = this.cumulativeHeights
2288
+ const total = this.totalRows
2289
+ let lo = 0, hi = total - 1
2290
+ while (lo < hi) {
2291
+ const mid = (lo + hi) >> 1
2292
+ if (cum[mid + 1] <= relY) lo = mid + 1
2293
+ else hi = mid
2294
+ }
2295
+ const row = Math.min(lo, total - 1)
2296
+
2297
+ // Columns are few — O(columns) is fast
2298
+ let left = this.getFrozenWidth()
2299
+ let col = 0
2300
+ for (let i = 0; i < this.leafCols.length; i++) {
2301
+ if (!this.isColumnScrollable(i)) continue
2302
+ left += this.getColW(i)
2303
+ if (relX < left) { col = i; break }
2304
+ col = i
2305
+ }
2306
+
2307
+ this.fillDrag.currentRow = row
2308
+ this.fillDrag.currentCol = col
2309
+ },
2310
+
2311
+ commitFillDrag() {
2312
+ const src = this.fillDrag.sourceRange
2313
+ if (!src) { this.fillDrag.active = false; return }
2314
+
2315
+ const targetRow = this.fillDrag.currentRow
2316
+ const targetCol = this.fillDrag.currentCol
2317
+
2318
+ const oldData = this.data
2319
+ const newData = oldData.slice() // only affected rows get new objects
2320
+ let targetRange
2321
+ const skipped = []
2322
+
2323
+ if (targetRow > src.endRow) {
2324
+ // Fill down
2325
+ const count = targetRow - src.endRow
2326
+ for (let ci = src.startCol; ci <= src.endCol; ci++) {
2327
+ const col = this.leafCols[ci]
2328
+ if (!col) continue
2329
+ const srcValues = []
2330
+ for (let ri = src.startRow; ri <= src.endRow; ri++) {
2331
+ srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')
2332
+ }
2333
+ const filled = generateFillValues(srcValues, count)
2334
+ for (let k = 0; k < count; k++) {
2335
+ const ri = src.endRow + 1 + k
2336
+ if (ri < this.totalRows && this.isCellEditable(ri, ci)) {
2337
+ if (!this.isCellValueValid(ri, ci, filled[k])) { skipped.push({ row: ri, col: ci, value: filled[k] }); continue }
2338
+ if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }
2339
+ newData[ri][col.key] = filled[k]
2340
+ }
2341
+ }
2342
+ }
2343
+ targetRange = { startRow: src.endRow + 1, endRow: targetRow, startCol: src.startCol, endCol: src.endCol }
2344
+ } else if (targetCol > src.endCol) {
2345
+ // Fill right
2346
+ const count = targetCol - src.endCol
2347
+ for (let ri = src.startRow; ri <= src.endRow; ri++) {
2348
+ const srcValues = []
2349
+ for (let ci = src.startCol; ci <= src.endCol; ci++) {
2350
+ const col = this.leafCols[ci]
2351
+ srcValues.push(col && oldData[ri] ? oldData[ri][col.key] : '')
2352
+ }
2353
+ const filled = generateFillValues(srcValues, count)
2354
+ for (let k = 0; k < count; k++) {
2355
+ const ci = src.endCol + 1 + k
2356
+ if (ci < this.leafCols.length) {
2357
+ const col = this.leafCols[ci]
2358
+ if (col && this.isCellEditable(ri, ci)) {
2359
+ if (!this.isCellValueValid(ri, ci, filled[k])) { skipped.push({ row: ri, col: ci, value: filled[k] }); continue }
2360
+ if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }
2361
+ newData[ri][col.key] = filled[k]
2362
+ }
2363
+ }
2364
+ }
2365
+ }
2366
+ targetRange = { startRow: src.startRow, endRow: src.endRow, startCol: src.endCol + 1, endCol: targetCol }
2367
+ }
2368
+
2369
+ if (targetRange) {
2370
+ if (this.strictBulkValidation && skipped.length > 0) {
2371
+ this._emitBulkValidationError(skipped, true)
2372
+ } else {
2373
+ this._emitDataChange(oldData, newData)
2374
+ this.$emit('fill', { sourceRange: src, targetRange, values: newData })
2375
+ if (skipped.length > 0) this._emitBulkValidationError(skipped, false)
2376
+ }
2377
+ }
2378
+
2379
+ this.fillDrag.active = false
2380
+ this.fillDrag.sourceRange = null
2381
+ },
2382
+
2383
+ doubleFillDown() {
2384
+ const pr = this.primaryRange
2385
+ if (!pr) return
2386
+ const src = normalizeRange(pr)
2387
+ // Find adjacent column filled length
2388
+ const adjCol = src.startCol > 0 ? src.startCol - 1 : src.endCol + 1
2389
+ if (adjCol < 0 || adjCol >= this.leafCols.length) return
2390
+ const adjColDef = this.leafCols[adjCol]
2391
+ if (!adjColDef) return
2392
+ let lastFilledRow = src.endRow
2393
+ for (let ri = src.endRow + 1; ri < this.totalRows; ri++) {
2394
+ if (this.data[ri] && this.data[ri][adjColDef.key] !== '' && this.data[ri][adjColDef.key] !== undefined) {
2395
+ lastFilledRow = ri
2396
+ } else break
2397
+ }
2398
+ if (lastFilledRow <= src.endRow) return
2399
+
2400
+ const count = lastFilledRow - src.endRow
2401
+ const oldData = this.data
2402
+ const newData = oldData.slice()
2403
+ const skipped = []
2404
+ for (let ci = src.startCol; ci <= src.endCol; ci++) {
2405
+ const col = this.leafCols[ci]
2406
+ if (!col) continue
2407
+ const srcValues = []
2408
+ for (let ri = src.startRow; ri <= src.endRow; ri++) {
2409
+ srcValues.push(oldData[ri] ? oldData[ri][col.key] : '')
2410
+ }
2411
+ const filled = generateFillValues(srcValues, count)
2412
+ for (let k = 0; k < count; k++) {
2413
+ const ri = src.endRow + 1 + k
2414
+ if (ri < this.totalRows && this.isCellEditable(ri, ci)) {
2415
+ if (!this.isCellValueValid(ri, ci, filled[k])) { skipped.push({ row: ri, col: ci, value: filled[k] }); continue }
2416
+ if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }
2417
+ newData[ri][col.key] = filled[k]
2418
+ }
2419
+ }
2420
+ }
2421
+ if (this.strictBulkValidation && skipped.length > 0) {
2422
+ this._emitBulkValidationError(skipped, true)
2423
+ return
2424
+ }
2425
+ this._emitDataChange(oldData, newData)
2426
+ if (skipped.length > 0) this._emitBulkValidationError(skipped, false)
2427
+ },
2428
+
2429
+ // =========================================================
2430
+ // COPY / PASTE (3.4)
2431
+ // =========================================================
2432
+ copySelection() {
2433
+ const pr = this.primaryRange
2434
+ if (!pr) return
2435
+ const r = normalizeRange(pr)
2436
+ this.clipboardMatrix = extractMatrix(this.data, this.leafCols, r)
2437
+ this.clipboardRange = r
2438
+ this.clipboardMode = 'copy'
2439
+
2440
+ // Write to system clipboard as TSV
2441
+ const tsv = matrixToTSV(this.clipboardMatrix)
2442
+ if (navigator.clipboard) {
2443
+ navigator.clipboard.writeText(tsv).catch(() => {})
2444
+ }
2445
+ },
2446
+
2447
+ cutSelection() {
2448
+ this.copySelection()
2449
+ this.clipboardMode = 'cut'
2450
+ },
2451
+
2452
+ async pasteAt(targetRow, targetCol, options = {}) {
2453
+ const { mode = 'all', transpose = false } = options
2454
+
2455
+ let matrix = null
2456
+
2457
+ // Try system clipboard first
2458
+ if (navigator.clipboard) {
2459
+ try {
2460
+ const text = await navigator.clipboard.readText()
2461
+ if (text) matrix = tsvToMatrix(text)
2462
+ } catch (_) {}
2463
+ }
2464
+
2465
+ // Fall back to internal clipboard
2466
+ if (!matrix && this.clipboardMatrix) {
2467
+ matrix = this.clipboardMatrix
2468
+ }
2469
+
2470
+ if (!matrix) return
2471
+
2472
+ if (transpose) matrix = transposeMatrix(matrix)
2473
+
2474
+ // ── Excel-style selection broadcast / tiling ──────────────────────────
2475
+ // When pasting over a multi-cell selection and the clipboard matrix is
2476
+ // smaller than the selection, tile the matrix to fill the selection —
2477
+ // provided the selection dimensions are exact multiples of the matrix
2478
+ // dimensions (matches Excel's tiling rule exactly).
2479
+ // Cut-paste is never tiled (source must be cleared exactly once).
2480
+ const isCopyPaste = !this.clipboardMode || this.clipboardMode === 'copy'
2481
+ if (isCopyPaste) {
2482
+ const pr = this.primaryRange
2483
+ if (pr) {
2484
+ const sel = normalizeRange(pr)
2485
+ const selRows = sel.endRow - sel.startRow + 1
2486
+ const selCols = sel.endCol - sel.startCol + 1
2487
+ const matRows = matrix.length
2488
+ const matCols = matrix[0] ? matrix[0].length : 0
2489
+
2490
+ // Only tile when selection is strictly larger than clipboard
2491
+ if ((selRows > matRows || selCols > matCols) &&
2492
+ selRows % matRows === 0 && selCols % matCols === 0) {
2493
+ matrix = tileMatrix(matrix, selRows, selCols)
2494
+ // Paste at selection origin, not just at the active cell
2495
+ targetRow = sel.startRow
2496
+ targetCol = sel.startCol
2497
+ }
2498
+ }
2499
+ }
2500
+
2501
+ const oldData = this.data
2502
+ const { newData, affectedRange, skipped } = applyMatrix(
2503
+ oldData, this.leafCols, targetRow, targetCol, matrix, mode,
2504
+ (ri, ci) => this.isCellEditable(ri, ci),
2505
+ (ri, ci, v) => this.isCellValueValid(ri, ci, v)
2506
+ )
2507
+
2508
+ if (this.strictBulkValidation && skipped.length > 0) {
2509
+ this._emitBulkValidationError(skipped, true)
2510
+ return
2511
+ }
2512
+
2513
+ if (this.clipboardMode === 'cut' && this.clipboardRange) {
2514
+ // Clear source — ensure each source row is a new copy before mutation
2515
+ const src = this.clipboardRange
2516
+ for (let ri = src.startRow; ri <= src.endRow; ri++) {
2517
+ if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }
2518
+ for (let ci = src.startCol; ci <= src.endCol; ci++) {
2519
+ const col = this.leafCols[ci]
2520
+ if (col && this.isCellEditable(ri, ci) && this.isCellValueValid(ri, ci, '')) {
2521
+ newData[ri][col.key] = ''
2522
+ }
2523
+ }
2524
+ }
2525
+ this.clipboardRange = null
2526
+ this.clipboardMode = null
2527
+ }
2528
+
2529
+ this._emitDataChange(oldData, newData)
2530
+ if (skipped.length > 0) this._emitBulkValidationError(skipped, false)
2531
+ },
2532
+
2533
+ ctxCopy() { this.hideContextMenu(); this.copySelection() },
2534
+ ctxCut() { this.hideContextMenu(); this.cutSelection() },
2535
+ ctxPaste() { this.hideContextMenu(); this.pasteAt(this.activeCell.row, this.activeCell.col) },
2536
+ ctxPasteSpecial(mode, transpose = false) {
2537
+ this.hideContextMenu()
2538
+ this.pasteAt(this.activeCell.row, this.activeCell.col, { mode, transpose })
2539
+ },
2540
+
2541
+ // =========================================================
2542
+ // RESIZE (3.5)
2543
+ // =========================================================
2544
+ startColResize(e, ci) {
2545
+ const rect = this.$refs.wrapper.getBoundingClientRect()
2546
+ this.resizing = {
2547
+ active: true,
2548
+ type: 'col',
2549
+ index: ci,
2550
+ startX: e.clientX,
2551
+ startY: e.clientY,
2552
+ startSize: this.getColW(ci),
2553
+ currentPos: e.clientX
2554
+ }
2555
+ e.preventDefault()
2556
+ },
2557
+
2558
+ startRowResize(e, ri) {
2559
+ this.resizing = {
2560
+ active: true,
2561
+ type: 'row',
2562
+ index: ri,
2563
+ startX: e.clientX,
2564
+ startY: e.clientY,
2565
+ startSize: this.getRowH(ri),
2566
+ currentPos: e.clientY
2567
+ }
2568
+ e.preventDefault()
2569
+ },
2570
+
2571
+ handleResizeMove(e) {
2572
+ if (this.resizing.type === 'col') {
2573
+ this.resizing.currentPos = e.clientX
2574
+ const newWidth = Math.max(20, this.resizing.startSize + (e.clientX - this.resizing.startX))
2575
+ this.$set(this.colWidths, this.resizing.index, newWidth)
2576
+ this._cellStyleCache = Object.create(null)
2577
+ } else {
2578
+ this.resizing.currentPos = e.clientY
2579
+ this._resizePreviewHeight = Math.max(10, this.resizing.startSize + (e.clientY - this.resizing.startY))
2580
+ // Batch $set + updateVirtualScroll to rAF — prevents O(n) cumulativeHeights rebuild
2581
+ // on every mousemove (200+ Hz). At most 60 rebuilds/sec instead of 200+.
2582
+ if (!this._resizeRAF) {
2583
+ this._resizeRAF = requestAnimationFrame(() => {
2584
+ this._resizeRAF = null
2585
+ this._rowStyleCache = Object.create(null)
2586
+ this._rowHeaderStyleCache = Object.create(null)
2587
+ this._cellStyleCache = Object.create(null)
2588
+ this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)
2589
+ this.updateVirtualScroll()
2590
+ })
2591
+ }
2592
+ }
2593
+ },
2594
+
2595
+ commitResize() {
2596
+ if (this._resizeRAF) {
2597
+ cancelAnimationFrame(this._resizeRAF)
2598
+ this._resizeRAF = null
2599
+ // Apply the last pending preview height immediately on commit
2600
+ if (this.resizing.type === 'row' && this._resizePreviewHeight !== undefined) {
2601
+ this.$set(this.rowHeights, this.resizing.index, this._resizePreviewHeight)
2602
+ }
2603
+ }
2604
+ if (this.resizing.type === 'col') {
2605
+ this._cellStyleCache = Object.create(null)
2606
+ const newSize = this.colWidths[this.resizing.index]
2607
+ this.$emit('column-resize', { index: this.resizing.index, newSize })
2608
+ } else {
2609
+ this._rowStyleCache = Object.create(null)
2610
+ this._rowHeaderStyleCache = Object.create(null)
2611
+ this._cellStyleCache = Object.create(null)
2612
+ const newSize = this.rowHeights[this.resizing.index]
2613
+ this.$emit('row-resize', { index: this.resizing.index, newSize })
2614
+ this.updateVirtualScroll()
2615
+ }
2616
+ this.resizing.active = false
2617
+ },
2618
+
2619
+ autoFitColumn(ci) {
2620
+ const col = this.leafCols[ci]
2621
+ if (!col) return
2622
+ let maxLen = (col.title || '').length
2623
+ for (let ri = 0; ri < Math.min(this.totalRows, 500); ri++) {
2624
+ const v = this.data[ri] ? String(this.data[ri][col.key] || '') : ''
2625
+ if (v.length > maxLen) maxLen = v.length
2626
+ }
2627
+ const newWidth = Math.max(50, maxLen * 8 + 16)
2628
+ this.$set(this.colWidths, ci, newWidth)
2629
+ this._cellStyleCache = Object.create(null)
2630
+ this.$emit('column-resize', { index: ci, newSize: newWidth })
2631
+ },
2632
+
2633
+ ctxHideRow() {
2634
+ this.hideContextMenu()
2635
+ const pr = this.primaryRange
2636
+ if (!pr) return
2637
+ const r = normalizeRange(pr)
2638
+ const newHidden = [...this.hiddenRows]
2639
+ for (let ri = r.startRow; ri <= r.endRow; ri++) {
2640
+ if (!newHidden.includes(ri)) newHidden.push(ri)
2641
+ }
2642
+ this.$emit('update:hiddenRows', newHidden)
2643
+ },
2644
+
2645
+ ctxHideColumn() {
2646
+ this.hideContextMenu()
2647
+ const pr = this.primaryRange
2648
+ if (!pr) return
2649
+ const r = normalizeRange(pr)
2650
+ const newHidden = [...this.hiddenColumns]
2651
+ for (let ci = r.startCol; ci <= r.endCol; ci++) {
2652
+ if (!newHidden.includes(ci)) newHidden.push(ci)
2653
+ }
2654
+ this.$emit('update:hiddenColumns', newHidden)
2655
+ },
2656
+
2657
+ ctxUnhideRows() {
2658
+ this.hideContextMenu()
2659
+ this.$emit('update:hiddenRows', [])
2660
+ },
2661
+
2662
+ ctxUnhideColumns() {
2663
+ this.hideContextMenu()
2664
+ this.$emit('update:hiddenColumns', [])
2665
+ },
2666
+
2667
+ ctxPinLeft() {
2668
+ const col = this.leafCols[this.contextMenu.col]
2669
+ if (!col) { this.hideContextMenu(); return }
2670
+ this.$emit('update:pinnedColumns', { ...this.pinnedColumns, [col.key]: 'left' })
2671
+ this.hideContextMenu()
2672
+ },
2673
+
2674
+ ctxPinRight() {
2675
+ const col = this.leafCols[this.contextMenu.col]
2676
+ if (!col) { this.hideContextMenu(); return }
2677
+ this.$emit('update:pinnedColumns', { ...this.pinnedColumns, [col.key]: 'right' })
2678
+ this.hideContextMenu()
2679
+ },
2680
+
2681
+ ctxUnpin() {
2682
+ const col = this.leafCols[this.contextMenu.col]
2683
+ if (!col) { this.hideContextMenu(); return }
2684
+ const next = { ...this.pinnedColumns }
2685
+ delete next[col.key]
2686
+ this.$emit('update:pinnedColumns', next)
2687
+ this.hideContextMenu()
2688
+ },
2689
+
2690
+ isColPinned(ci) {
2691
+ const col = this.leafCols[ci]
2692
+ return !!(col && this.pinnedColumns[col.key])
2693
+ },
2694
+
2695
+ // =========================================================
2696
+ // MERGE CELLS (3.8)
2697
+ // =========================================================
2698
+ mergeCells(range) {
2699
+ const oldMerges = this.internalMerges.slice()
2700
+ const { merges: newMerges, conflict } = addMerge(range, this.internalMerges)
2701
+ if (conflict) {
2702
+ this.$emit('merge-error', { range, reason: 'Overlaps existing merge' })
2703
+ return false
2704
+ }
2705
+ this.internalMerges = newMerges
2706
+ this._emitMergeChange(oldMerges, [...newMerges])
2707
+ return true
2708
+ },
2709
+
2710
+ unmergeCells(row, col) {
2711
+ const oldMerges = this.internalMerges.slice()
2712
+ const newMerges = removeMergeAt(row, col, this.internalMerges)
2713
+ this.internalMerges = newMerges
2714
+ this._emitMergeChange(oldMerges, [...newMerges])
2715
+ },
2716
+
2717
+ ctxMerge() {
2718
+ this.hideContextMenu()
2719
+ const pr = this.primaryRange
2720
+ if (!pr) return
2721
+ this.mergeCells(normalizeRange(pr))
2722
+ },
2723
+
2724
+ ctxUnmerge() {
2725
+ this.hideContextMenu()
2726
+ this.unmergeCells(this.activeCell.row, this.activeCell.col)
2727
+ },
2728
+
2729
+ // =========================================================
2730
+ // GROUPING (3.7)
2731
+ // =========================================================
2732
+ groupRows(startIndex, endIndex) {
2733
+ this.internalGroups = addGroup('row', startIndex, endIndex, this.internalGroups)
2734
+ },
2735
+
2736
+ groupColumns(startIndex, endIndex) {
2737
+ this.internalGroups = addGroup('column', startIndex, endIndex, this.internalGroups)
2738
+ },
2739
+
2740
+ ungroupRows(startIndex, endIndex) {
2741
+ this.internalGroups = removeGroup('row', startIndex, endIndex, this.internalGroups)
2742
+ },
2743
+
2744
+ ungroupColumns(startIndex, endIndex) {
2745
+ this.internalGroups = removeGroup('column', startIndex, endIndex, this.internalGroups)
2746
+ },
2747
+
2748
+ toggleGroup(group) {
2749
+ this.internalCollapsedGroups = toggleGroupCollapse(group, this.internalCollapsedGroups)
2750
+ this.$emit('update:collapsedGroups', [...this.internalCollapsedGroups])
2751
+ this.$emit('group-toggle', { group, collapsed: _isGroupCollapsed(group, this.internalCollapsedGroups) })
2752
+ this.updateVirtualScroll()
2753
+ },
2754
+
2755
+ isGroupCollapsed(group) {
2756
+ return _isGroupCollapsed(group, this.internalCollapsedGroups)
2757
+ },
2758
+
2759
+ expandAll() {
2760
+ this.internalCollapsedGroups = []
2761
+ this.$emit('update:collapsedGroups', [])
2762
+ this.updateVirtualScroll()
2763
+ },
2764
+
2765
+ collapseAll() {
2766
+ this.internalCollapsedGroups = [...this.internalGroups]
2767
+ this.$emit('update:collapsedGroups', [...this.internalGroups])
2768
+ this.updateVirtualScroll()
2769
+ },
2770
+
2771
+ // =========================================================
2772
+ // DRAG AND DROP (3.9)
2773
+ // =========================================================
2774
+ startDragMove(e, ri, ci) {
2775
+ const pr = this.primaryRange
2776
+ if (!pr) return
2777
+ this.dragMove = {
2778
+ active: true,
2779
+ sourceRange: normalizeRange(pr),
2780
+ startX: e.clientX,
2781
+ startY: e.clientY,
2782
+ targetRow: ri,
2783
+ targetCol: ci,
2784
+ copyMode: e.ctrlKey || e.metaKey
2785
+ }
2786
+ this.$emit('cell-drag-start', { sourceRange: this.dragMove.sourceRange })
2787
+ e.preventDefault()
2788
+ },
2789
+
2790
+ handleDragMoveMove(e) {
2791
+ this.dragMove.copyMode = e.ctrlKey || e.metaKey
2792
+ this.$emit('cell-drag-move', {
2793
+ sourceRange: this.dragMove.sourceRange,
2794
+ targetRow: this.dragMove.targetRow,
2795
+ targetCol: this.dragMove.targetCol
2796
+ })
2797
+ },
2798
+
2799
+ async commitDragMove(e) {
2800
+ if (!this.dragMove.active) return
2801
+ const { sourceRange, targetRow, targetCol, copyMode } = this.dragMove
2802
+
2803
+ const targetRange = {
2804
+ startRow: targetRow,
2805
+ startCol: targetCol,
2806
+ endRow: targetRow + (sourceRange.endRow - sourceRange.startRow),
2807
+ endCol: targetCol + (sourceRange.endCol - sourceRange.startCol)
2808
+ }
2809
+
2810
+ let proceed = true
2811
+ if (this.beforeDrop) {
2812
+ proceed = await Promise.resolve(this.beforeDrop(sourceRange, targetRange))
2813
+ }
2814
+
2815
+ if (proceed) {
2816
+ const oldData = this.data
2817
+ const matrix = extractMatrix(oldData, this.leafCols, sourceRange)
2818
+ const { newData } = applyMatrix(oldData, this.leafCols, targetRow, targetCol, matrix, 'all', (ri, ci) => this.isCellEditable(ri, ci))
2819
+
2820
+ if (!copyMode) {
2821
+ // Clear source — ensure each source row is a new copy before mutation
2822
+ for (let ri = sourceRange.startRow; ri <= sourceRange.endRow; ri++) {
2823
+ if (newData[ri] === oldData[ri]) newData[ri] = { ...oldData[ri] }
2824
+ for (let ci = sourceRange.startCol; ci <= sourceRange.endCol; ci++) {
2825
+ const col = this.leafCols[ci]
2826
+ if (col && this.isCellEditable(ri, ci)) newData[ri][col.key] = ''
2827
+ }
2828
+ }
2829
+ }
2830
+ this._emitDataChange(oldData, newData)
2831
+ this.$emit('cell-drop', { sourceRange, targetRange })
2832
+ }
2833
+
2834
+ this.dragMove = { active: false, sourceRange: null, startX: 0, startY: 0, targetRow: -1, targetCol: -1, copyMode: false }
2835
+ this.isMouseDown = false
2836
+ },
2837
+
2838
+ // =========================================================
2839
+ // EXCEL EXPORT (3.11)
2840
+ // =========================================================
2841
+ async exportToExcel(options = {}) {
2842
+ const {
2843
+ filename = 'export.xlsx',
2844
+ sheetName = 'Sheet1',
2845
+ scope = 'all',
2846
+ includeHidden = false
2847
+ } = options
2848
+
2849
+ this.$emit('export-start')
2850
+ this.exportState = { active: true }
2851
+
2852
+ const workerPayload = {
2853
+ data: this.data,
2854
+ columns: this.columns,
2855
+ leafCols: this.leafCols,
2856
+ merges: this.internalMerges,
2857
+ hiddenCols: [...this.effectiveHiddenColSet],
2858
+ hiddenRows: [...this.hiddenRowSet],
2859
+ range: scope === 'selection' ? this.primaryRange : null,
2860
+ filename,
2861
+ sheetName,
2862
+ scope,
2863
+ includeHidden
2864
+ }
2865
+
2866
+ try {
2867
+ const worker = createExcelExportWorker()
2868
+ if (worker) {
2869
+ await this._exportViaWorker(worker, workerPayload)
2870
+ } else {
2871
+ // Fallback: synchronous export on main thread
2872
+ _exportToExcel({
2873
+ ...workerPayload,
2874
+ hiddenColSet: this.effectiveHiddenColSet,
2875
+ hiddenRowSet: this.hiddenRowSet
2876
+ })
2877
+ }
2878
+ this.$emit('export-success')
2879
+ } catch (err) {
2880
+ this.$emit('export-error', err)
2881
+ } finally {
2882
+ this.exportState = { active: false }
2883
+ }
2884
+ },
2885
+
2886
+ _exportViaWorker(worker, payload) {
2887
+ return new Promise((resolve, reject) => {
2888
+ let done = false
2889
+ const finish = (fn, val) => {
2890
+ if (done) return
2891
+ done = true
2892
+ worker.terminate()
2893
+ fn(val)
2894
+ }
2895
+
2896
+ worker.onmessage = (e) => {
2897
+ const { type, buffer, filename, message } = e.data
2898
+ if (type === 'result') {
2899
+ // Trigger browser download from the main thread (DOM required)
2900
+ const blob = new Blob([new Uint8Array(buffer)], {
2901
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
2902
+ })
2903
+ const url = URL.createObjectURL(blob)
2904
+ const a = document.createElement('a')
2905
+ a.href = url
2906
+ a.download = filename
2907
+ document.body.appendChild(a)
2908
+ a.click()
2909
+ document.body.removeChild(a)
2910
+ URL.revokeObjectURL(url)
2911
+ finish(resolve)
2912
+ } else if (type === 'error') {
2913
+ finish(reject, new Error(message))
2914
+ }
2915
+ }
2916
+
2917
+ worker.onerror = (err) => finish(reject, new Error(err.message || 'Export worker xatosi'))
2918
+
2919
+ worker.postMessage(payload)
2920
+ })
2921
+ },
2922
+
2923
+ // =========================================================
2924
+ // EXCEL IMPORT (3.12)
2925
+ // =========================================================
2926
+ async importFromFile(file, options = {}) {
2927
+ this.importState = { active: true, percent: 5, message: "Fayl o'qilmoqda..." }
2928
+
2929
+ try {
2930
+ let result
2931
+ const worker = createExcelImportWorker()
2932
+
2933
+ if (worker) {
2934
+ result = await this._importViaWorker(worker, file, options)
2935
+ } else {
2936
+ // Fallback: chunked async on main thread
2937
+ result = await _importFromFile(
2938
+ file,
2939
+ this.columns,
2940
+ options,
2941
+ (percent, message) => { this.importState = { active: true, percent, message } }
2942
+ )
2943
+ }
2944
+
2945
+ const { columns, merges } = result
2946
+ // Freeze each row so Vue 2 skips deep Object.defineProperty observation.
2947
+ // Vue 2 checks Object.isExtensible() — frozen objects are skipped entirely.
2948
+ // All edit paths already use spread ({ ...row }) so frozen rows are safe.
2949
+ const data = result.data.map(row => Object.freeze(row))
2950
+ this.$emit('input', data)
2951
+ this.$emit('update:columns', columns)
2952
+
2953
+ const hasMerges = merges && merges.length > 0
2954
+ this.internalMerges = hasMerges ? merges : []
2955
+ this.$emit('update:merges', hasMerges ? merges : [])
2956
+ this.$emit('update:collapsedGroups', [])
2957
+ this.$emit('update:hiddenRows', [])
2958
+ this.$emit('import-success', { data, columns, merges })
2959
+
2960
+ // Reset scroll state and re-derive virtual scroll after DOM settles.
2961
+ // Without this, stale scrollTop + old rowHeights cause blank bands on scroll.
2962
+ this.rowHeights = {}
2963
+ this.scrollTop = 0
2964
+ this.scrollLeft = 0
2965
+ this.$nextTick(() => {
2966
+ if (this.$refs.scrollBody) {
2967
+ this.$refs.scrollBody.scrollTop = 0
2968
+ this.$refs.scrollBody.scrollLeft = 0
2969
+ }
2970
+ if (this.$refs.scrollColHeader) this.$refs.scrollColHeader.scrollLeft = 0
2971
+ if (this.$refs.scrollRowHeaders) this.$refs.scrollRowHeaders.scrollTop = 0
2972
+ this.updateVirtualScroll()
2973
+ })
2974
+ } catch (err) {
2975
+ this.$emit('import-error', err)
2976
+ } finally {
2977
+ this.importState = { active: false, percent: 0, message: '' }
2978
+ }
2979
+ },
2980
+
2981
+ _importViaWorker(worker, file, options) {
2982
+ return new Promise(async (resolve, reject) => {
2983
+ let done = false
2984
+ const finish = (fn, val) => {
2985
+ if (done) return
2986
+ done = true
2987
+ worker.terminate()
2988
+ fn(val)
2989
+ }
2990
+
2991
+ worker.onmessage = (e) => {
2992
+ const { type, percent, message, data, columns, merges } = e.data
2993
+ if (type === 'progress') {
2994
+ this.importState = { active: true, percent, message }
2995
+ } else if (type === 'result') {
2996
+ finish(resolve, { data, columns, merges })
2997
+ } else if (type === 'error') {
2998
+ finish(reject, new Error(message))
2999
+ }
3000
+ }
3001
+
3002
+ worker.onerror = (err) => finish(reject, new Error(err.message || 'Import worker xatosi'))
3003
+
3004
+ try {
3005
+ // Transfer the ArrayBuffer zero-copy into the worker
3006
+ const buffer = await file.arrayBuffer()
3007
+ worker.postMessage(
3008
+ { buffer, existingColumns: this.columns, options },
3009
+ [buffer]
3010
+ )
3011
+ } catch (err) {
3012
+ finish(reject, err)
3013
+ }
3014
+ })
3015
+ },
3016
+
3017
+ // =========================================================
3018
+ // UNDO / REDO (3.13)
3019
+ // =========================================================
3020
+
3021
+ // Push one entry onto the non-reactive history stack.
3022
+ // Truncates the redo tail, caps at 100 entries.
3023
+ _pushHistory(entry) {
3024
+ if (this._historyIndex < this._history.length - 1) {
3025
+ this._history.splice(this._historyIndex + 1)
3026
+ }
3027
+ this._history.push(entry)
3028
+ if (this._history.length > 100) {
3029
+ this._history.shift()
3030
+ // historyIndex stays the same — it now points to the new last entry
3031
+ } else {
3032
+ this._historyIndex++
3033
+ }
3034
+ },
3035
+
3036
+ // Emit 'input' and record a data diff entry (only changed row indices).
3037
+ // Use slice() + per-row spread in callers so unchanged rows keep the
3038
+ // same reference — then the identity check here catches only real changes.
3039
+ _emitDataChange(oldData, newData) {
3040
+ if (!this._historySkip) {
3041
+ const indices = []
3042
+ const beforeRows = []
3043
+ const afterRows = []
3044
+ const len = Math.max(oldData.length, newData.length)
3045
+ for (let i = 0; i < len; i++) {
3046
+ if (oldData[i] !== newData[i]) {
3047
+ indices.push(i)
3048
+ beforeRows.push(oldData[i])
3049
+ afterRows.push(newData[i])
3050
+ }
3051
+ }
3052
+ if (indices.length > 0) {
3053
+ this._pushHistory({
3054
+ type: 'data',
3055
+ before: { indices, rows: beforeRows },
3056
+ after: { indices, rows: afterRows }
3057
+ })
3058
+ }
3059
+ }
3060
+ this.$emit('input', newData)
3061
+ },
3062
+
3063
+ // Emit 'update:merges' and record a merges diff entry.
3064
+ _emitMergeChange(oldMerges, newMerges) {
3065
+ if (!this._historySkip) {
3066
+ this._pushHistory({
3067
+ type: 'merges',
3068
+ before: oldMerges,
3069
+ after: newMerges
3070
+ })
3071
+ }
3072
+ this.$emit('update:merges', newMerges)
3073
+ },
3074
+
3075
+ undo() {
3076
+ if (this._historyIndex < 0) return
3077
+ const entry = this._history[this._historyIndex--]
3078
+ this._historySkip = true
3079
+ try {
3080
+ if (entry.type === 'data') {
3081
+ const newData = this.data.slice()
3082
+ for (let i = 0; i < entry.before.indices.length; i++) {
3083
+ newData[entry.before.indices[i]] = entry.before.rows[i]
3084
+ }
3085
+ this.$emit('input', newData)
3086
+ } else if (entry.type === 'merges') {
3087
+ this.internalMerges = entry.before.slice()
3088
+ this.$emit('update:merges', entry.before.slice())
3089
+ }
3090
+ } finally {
3091
+ this._historySkip = false
3092
+ }
3093
+ },
3094
+
3095
+ redo() {
3096
+ if (this._historyIndex >= this._history.length - 1) return
3097
+ const entry = this._history[++this._historyIndex]
3098
+ this._historySkip = true
3099
+ try {
3100
+ if (entry.type === 'data') {
3101
+ const newData = this.data.slice()
3102
+ for (let i = 0; i < entry.after.indices.length; i++) {
3103
+ newData[entry.after.indices[i]] = entry.after.rows[i]
3104
+ }
3105
+ this.$emit('input', newData)
3106
+ } else if (entry.type === 'merges') {
3107
+ this.internalMerges = entry.after.slice()
3108
+ this.$emit('update:merges', entry.after.slice())
3109
+ }
3110
+ } finally {
3111
+ this._historySkip = false
3112
+ }
3113
+ }
3114
+ }
3115
+ }
3116
+ </script>
3117
+
3118
+ <style scoped>
3119
+ .eg-wrapper {
3120
+ display: flex;
3121
+ flex-direction: column;
3122
+ overflow: hidden;
3123
+ box-sizing: border-box;
3124
+ border: 1px solid #ccc;
3125
+ border-radius: 4px;
3126
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
3127
+ }
3128
+
3129
+ .eg-container {
3130
+ display: flex;
3131
+ flex-direction: column;
3132
+ flex: 1;
3133
+ overflow: auto;
3134
+ position: relative;
3135
+ }
3136
+
3137
+ .eg-header-area {
3138
+ display: flex;
3139
+ flex-shrink: 0;
3140
+ box-shadow: 0 2px 0 0 #d0d0d0;
3141
+ position: relative;
3142
+ z-index: 31;
3143
+ }
3144
+
3145
+ .eg-header-cell {
3146
+ display: flex;
3147
+ align-items: center;
3148
+ justify-content: center;
3149
+ box-sizing: border-box;
3150
+ border-right: 1px solid #ccc;
3151
+ border-bottom: 1px solid #ccc;
3152
+ background: #f0f0f0;
3153
+ font-size: 12px;
3154
+ font-weight: 600;
3155
+ overflow: hidden;
3156
+ white-space: nowrap;
3157
+ position: absolute;
3158
+ user-select: none;
3159
+ cursor: pointer;
3160
+ }
3161
+
3162
+ .eg-header-cell:hover { background: #e0e0e0; }
3163
+ .eg-header-group { background: #e8eaf6; }
3164
+
3165
+ .eg-col-resize-handle {
3166
+ position: absolute;
3167
+ right: 0;
3168
+ top: 0;
3169
+ bottom: 0;
3170
+ width: 5px;
3171
+ cursor: col-resize;
3172
+ z-index: 5;
3173
+ }
3174
+
3175
+ .eg-body-area {
3176
+ display: flex;
3177
+ flex: 1;
3178
+ overflow: hidden;
3179
+ }
3180
+
3181
+ .eg-row {
3182
+ position: relative;
3183
+ }
3184
+
3185
+ .eg-cell {
3186
+ box-sizing: border-box;
3187
+ border-right: 1px solid #e0e0e0;
3188
+ border-bottom: 1px solid #e0e0e0;
3189
+ overflow: hidden;
3190
+ white-space: nowrap;
3191
+ font-size: 13px;
3192
+ padding: 0 4px;
3193
+ position: absolute;
3194
+ top: 0;
3195
+ background: white;
3196
+ cursor: default;
3197
+ display: flex;
3198
+ align-items: center;
3199
+ }
3200
+
3201
+ .eg-cell-active {
3202
+ outline: 2px solid #1a73e8 !important;
3203
+ outline-offset: -2px;
3204
+ z-index: 3;
3205
+ }
3206
+
3207
+ .eg-cell-selected {
3208
+ background: #e3f2fd !important;
3209
+ }
3210
+
3211
+ .eg-cell-fill-preview {
3212
+ background: #bbdefb !important;
3213
+ outline: 1px dashed #1565c0;
3214
+ outline-offset: -1px;
3215
+ }
3216
+
3217
+ .eg-cell-copy {
3218
+ outline: 1px dashed #1a73e8;
3219
+ outline-offset: -1px;
3220
+ }
3221
+
3222
+ .eg-cell-merged-hidden {
3223
+ display: none !important;
3224
+ }
3225
+
3226
+ .eg-cell-invalid {
3227
+ outline: 2px solid #e53935 !important;
3228
+ outline-offset: -2px;
3229
+ z-index: 4;
3230
+ }
3231
+
3232
+ .eg-cell-input {
3233
+ width: 100%;
3234
+ height: 100%;
3235
+ border: none;
3236
+ outline: none;
3237
+ background: white;
3238
+ font-size: 13px;
3239
+ font-family: inherit;
3240
+ padding: 0;
3241
+ }
3242
+
3243
+ .eg-row-header-cell {
3244
+ box-sizing: border-box;
3245
+ background: #f0f0f0;
3246
+ border-bottom: 1px solid #e0e0e0;
3247
+ border-right: 2px solid #bbb;
3248
+ text-align: center;
3249
+ font-size: 11px;
3250
+ color: #666;
3251
+ cursor: pointer;
3252
+ user-select: none;
3253
+ position: relative;
3254
+ display: flex;
3255
+ align-items: center;
3256
+ justify-content: center;
3257
+ }
3258
+
3259
+ .eg-row-resize-handle {
3260
+ position: absolute;
3261
+ bottom: 0;
3262
+ left: 0;
3263
+ right: 0;
3264
+ height: 4px;
3265
+ cursor: row-resize;
3266
+ z-index: 5;
3267
+ }
3268
+
3269
+ .eg-fill-handle {
3270
+ position: absolute;
3271
+ width: 6px;
3272
+ height: 6px;
3273
+ background: #1a73e8;
3274
+ border: 1px solid white;
3275
+ cursor: crosshair;
3276
+ z-index: 10;
3277
+ }
3278
+
3279
+ .eg-context-menu {
3280
+ position: fixed;
3281
+ background: white;
3282
+ border: 1px solid #ccc;
3283
+ border-radius: 4px;
3284
+ box-shadow: 2px 4px 12px rgba(0,0,0,0.15);
3285
+ z-index: 9999;
3286
+ min-width: 160px;
3287
+ font-size: 13px;
3288
+ padding: 4px 0;
3289
+ }
3290
+
3291
+ .eg-context-item {
3292
+ padding: 6px 16px;
3293
+ cursor: pointer;
3294
+ position: relative;
3295
+ }
3296
+
3297
+ .eg-context-item:hover { background: #f0f0f0; }
3298
+
3299
+ .eg-context-separator {
3300
+ height: 1px;
3301
+ background: #e0e0e0;
3302
+ margin: 4px 0;
3303
+ }
3304
+
3305
+ .eg-context-submenu-parent::after { content: ' ▶'; float: right; }
3306
+
3307
+ .eg-context-submenu {
3308
+ display: none;
3309
+ position: absolute;
3310
+ left: 100%;
3311
+ top: -4px;
3312
+ background: white;
3313
+ border: 1px solid #ccc;
3314
+ border-radius: 4px;
3315
+ box-shadow: 2px 4px 12px rgba(0,0,0,0.15);
3316
+ min-width: 160px;
3317
+ padding: 4px 0;
3318
+ z-index: 10000;
3319
+ }
3320
+
3321
+ .eg-context-submenu-parent:hover .eg-context-submenu { display: block; }
3322
+
3323
+ .eg-group-btn {
3324
+ position: absolute;
3325
+ width: 16px;
3326
+ height: 16px;
3327
+ line-height: 14px;
3328
+ text-align: center;
3329
+ font-size: 10px;
3330
+ cursor: pointer;
3331
+ background: #e8eaf6;
3332
+ border: 1px solid #9fa8da;
3333
+ border-radius: 2px;
3334
+ z-index: 5;
3335
+ user-select: none;
3336
+ }
3337
+
3338
+ .eg-group-btn.collapsed { background: #c5cae9; }
3339
+
3340
+ .eg-drag-ghost {
3341
+ position: absolute;
3342
+ border: 2px dashed #1a73e8;
3343
+ pointer-events: none;
3344
+ z-index: 20;
3345
+ }
3346
+
3347
+ .eg-resize-line {
3348
+ pointer-events: none;
3349
+ z-index: 9999;
3350
+ }
3351
+
3352
+ /* Pin left separator shadow */
3353
+ .eg-frozen-col-body::after {
3354
+ content: '';
3355
+ position: absolute;
3356
+ top: 0;
3357
+ right: -4px;
3358
+ width: 4px;
3359
+ height: 100%;
3360
+ background: linear-gradient(to right, rgba(0,0,0,0.1), transparent);
3361
+ pointer-events: none;
3362
+ }
3363
+
3364
+ .eg-pinned-right-body {
3365
+ position: sticky;
3366
+ right: 0;
3367
+ z-index: 10;
3368
+ background: white;
3369
+ flex-shrink: 0;
3370
+ }
3371
+
3372
+ .eg-pinned-right-body::before {
3373
+ content: '';
3374
+ position: absolute;
3375
+ top: 0;
3376
+ left: -4px;
3377
+ bottom: 0;
3378
+ width: 4px;
3379
+ background: linear-gradient(to left, rgba(0,0,0,0.10), transparent);
3380
+ pointer-events: none;
3381
+ }
3382
+
3383
+ .eg-pinned-right-header {
3384
+ overflow: hidden;
3385
+ flex-shrink: 0;
3386
+ border-left: 2px solid #bbb;
3387
+ background: #f0f0f0;
3388
+ }
3389
+
3390
+ .eg-import-overlay {
3391
+ position: absolute;
3392
+ inset: 0;
3393
+ background: rgba(255, 255, 255, 0.85);
3394
+ display: flex;
3395
+ align-items: center;
3396
+ justify-content: center;
3397
+ z-index: 100;
3398
+ border-radius: 4px;
3399
+ }
3400
+
3401
+ .eg-import-dialog {
3402
+ background: white;
3403
+ border: 1px solid #e0e0e0;
3404
+ border-radius: 8px;
3405
+ padding: 24px 32px;
3406
+ min-width: 280px;
3407
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
3408
+ text-align: center;
3409
+ }
3410
+
3411
+ .eg-import-title {
3412
+ font-size: 14px;
3413
+ font-weight: 600;
3414
+ color: #333;
3415
+ margin-bottom: 16px;
3416
+ }
3417
+
3418
+ .eg-import-bar-wrap {
3419
+ height: 8px;
3420
+ background: #e0e0e0;
3421
+ border-radius: 4px;
3422
+ overflow: hidden;
3423
+ margin-bottom: 10px;
3424
+ }
3425
+
3426
+ .eg-import-bar {
3427
+ height: 100%;
3428
+ background: linear-gradient(to right, #1a73e8, #4caf50);
3429
+ border-radius: 4px;
3430
+ transition: width 0.15s ease;
3431
+ }
3432
+
3433
+ .eg-import-msg {
3434
+ font-size: 12px;
3435
+ color: #666;
3436
+ margin-bottom: 4px;
3437
+ }
3438
+
3439
+ .eg-import-pct {
3440
+ font-size: 13px;
3441
+ font-weight: 600;
3442
+ color: #1a73e8;
3443
+ }
3444
+ .eg-export-spinner {
3445
+ width: 32px;
3446
+ height: 32px;
3447
+ margin: 12px auto 0;
3448
+ border: 3px solid #e0e0e0;
3449
+ border-top-color: #1a73e8;
3450
+ border-radius: 50%;
3451
+ animation: eg-spin 0.8s linear infinite;
3452
+ }
3453
+ @keyframes eg-spin {
3454
+ to { transform: rotate(360deg); }
3455
+ }
3456
+
3457
+ /* ===== READONLY CELL ===== */
3458
+ .eg-cell-readonly {
3459
+ background: #f8f8f8;
3460
+ cursor: default;
3461
+ }
3462
+ .eg-cell-readonly.eg-cell-selected {
3463
+ background: #dce8f5;
3464
+ }
3465
+ .eg-cell-readonly.eg-cell-active {
3466
+ outline-color: #aaa;
3467
+ }
3468
+
3469
+ /* ===== SELECT DROPDOWN ===== */
3470
+ .eg-cell-select {
3471
+ cursor: pointer;
3472
+ }
3473
+ .eg-cell-select::after {
3474
+ content: '▾';
3475
+ position: absolute;
3476
+ right: 4px;
3477
+ top: 50%;
3478
+ transform: translateY(-50%);
3479
+ color: #aaa;
3480
+ font-size: 11px;
3481
+ pointer-events: none;
3482
+ line-height: 1;
3483
+ }
3484
+
3485
+ /* ===== CHECKBOX CELL ===== */
3486
+ .eg-cell-checkbox {
3487
+ justify-content: center;
3488
+ }
3489
+ .eg-cell-checkbox-input {
3490
+ width: 15px;
3491
+ height: 15px;
3492
+ margin: 0;
3493
+ cursor: pointer;
3494
+ }
3495
+ .eg-cell-checkbox-input:disabled {
3496
+ cursor: default;
3497
+ }
3498
+
3499
+ .eg-select-dropdown {
3500
+ background: white;
3501
+ border: 1px solid #d0d0d0;
3502
+ border-radius: 4px;
3503
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
3504
+ overflow-y: auto;
3505
+ max-height: 200px;
3506
+ padding: 4px 0;
3507
+ }
3508
+
3509
+ .eg-select-option {
3510
+ display: flex;
3511
+ align-items: center;
3512
+ gap: 8px;
3513
+ padding: 6px 12px;
3514
+ font-size: 13px;
3515
+ cursor: pointer;
3516
+ white-space: nowrap;
3517
+ user-select: none;
3518
+ color: #333;
3519
+ }
3520
+ .eg-select-option:hover,
3521
+ .eg-select-option--highlighted {
3522
+ background: #f0f4ff;
3523
+ }
3524
+ .eg-select-option--active {
3525
+ font-weight: 600;
3526
+ color: #1a73e8;
3527
+ }
3528
+ .eg-select-option--active.eg-select-option--highlighted,
3529
+ .eg-select-option--active:hover {
3530
+ background: #e8f0fe;
3531
+ }
3532
+
3533
+ .eg-validation-tooltip {
3534
+ background: #fff0f0;
3535
+ border: 1px solid #e53935;
3536
+ color: #c62828;
3537
+ font-size: 12px;
3538
+ padding: 4px 8px;
3539
+ border-radius: 4px;
3540
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
3541
+ white-space: nowrap;
3542
+ pointer-events: none;
3543
+ }
3544
+
3545
+ .eg-select-dot {
3546
+ width: 9px;
3547
+ height: 9px;
3548
+ border-radius: 50%;
3549
+ flex-shrink: 0;
3550
+ display: inline-block;
3551
+ }
3552
+ </style>