/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/components/Grid.tsx
1 673 строки
61 KB
Anton Kravchenkov
perf: Improve INP
16 мар 2026, 22:36
16 мар 2026, 22:36
421fee2
Код
Авторство
О чём код?
import React, { useCallback, useRef, useState, useEffect, useLayoutEffect, useMemo } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { type CellPosition, type CellRange, type CellStyle, type SheetData, type CellData, type TextRun, DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, ROW_HEADER_WIDTH, COL_HEADER_HEIGHT, TOTAL_ROWS, TOTAL_COLS, } from '../types/types.ts'; import { cellId, parseCellId, colToLetter, isCellInRange, normalizeRange, getRangeCells, formatCellValue, parseCellDate } from '../utils/cellUtils'; import { textRunsToHtml, htmlToTextRuns, textRunsToPlainText, sanitizeRuns, isPlainText } from '../utils/richTextUtils'; import { ContextMenu, type ContextMenuItem } from './ContextMenu'; type MergeInfo = { isOrigin: true; range: CellRange } | { isOrigin: false }; // Singleton off-screen element for measuring wrapped text height let measureEl: HTMLDivElement | null = null; function getMeasureEl(): HTMLDivElement { if (!measureEl) { measureEl = document.createElement('div'); measureEl.style.position = 'absolute'; measureEl.style.visibility = 'hidden'; measureEl.style.whiteSpace = 'pre-wrap'; measureEl.style.wordWrap = 'break-word'; measureEl.style.overflowWrap = 'break-word'; measureEl.style.padding = '0 4px'; measureEl.style.lineHeight = '1.4'; measureEl.style.fontFamily = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif"; document.body.appendChild(measureEl); } return measureEl; } function measureWrappedHeight(text: string, width: number, style?: CellStyle): number { const el = getMeasureEl(); el.style.width = `${width}px`; el.style.fontSize = style?.fontSize ? `${style.fontSize}px` : '13px'; el.style.fontWeight = style?.bold ? 'bold' : 'normal'; el.style.fontStyle = style?.italic ? 'italic' : 'normal'; el.textContent = text; return el.offsetHeight; } interface GridProps { sheet: SheetData; selectedCell: CellPosition | null; selectedRange: CellRange | null; isEditing: boolean; editValue: string; clipboard: { range: CellRange | null; isCut: boolean } | null; onSelectCell: (pos: CellPosition) => void; onSelectRange: (range: CellRange) => void; onStartEditing: (value?: string) => void; onEditValueChange: (value: string) => void; onStopEditing: (save: boolean, richText?: TextRun[], finalValue?: string) => void; onSetColumnWidth: (col: number, width: number) => void; onSetRowHeight: (row: number, height: number) => void; onCopy: (isCut: boolean) => void; onPaste: () => void; onClearClipboard: () => void; onDeleteContent: () => void; onInsertRow: (row: number) => void; onInsertColumn: (col: number) => void; onDeleteRow: (row: number) => void; onDeleteColumn: (col: number) => void; onInsertLink: () => void; onRemoveLink: () => void; onMergeCells: () => void; onUnmergeCells: () => void; onSetFrozenRows: (count: number) => void; onSetFrozenCols: (count: number) => void; readOnly?: boolean; commentCells?: Set<string>; commentOpenCell?: { row: number; col: number } | null; onOpenComments?: (row: number, col: number, anchorRect: DOMRect) => void; otherUsersSelections?: Record<string, { userId: string; username: string; avatarColor?: string; tabId: string; selectedCell: { row: number; col: number }; selectedRange: { start: { row: number; col: number }; end: { row: number; col: number } }; }>; onVisibleRangeChange?: (range: { startRow: number; endRow: number; startCol: number; endCol: number }) => void; onFillDates?: (sourceRow: number, sourceCol: number, fillRange: CellRange) => void; } const GridInner: React.FC<GridProps> = ({ sheet, selectedCell, selectedRange, isEditing, editValue, clipboard, onSelectCell, onSelectRange, onStartEditing, onEditValueChange, onStopEditing, onSetColumnWidth, onSetRowHeight, onCopy, onPaste, onClearClipboard, onDeleteContent, onInsertRow, onInsertColumn, onDeleteRow, onDeleteColumn, onInsertLink, onRemoveLink, onMergeCells, onUnmergeCells, onSetFrozenRows, onSetFrozenCols, readOnly = false, commentCells, commentOpenCell, onOpenComments, otherUsersSelections = {}, onVisibleRangeChange, onFillDates, }) => { const containerRef = useRef<HTMLDivElement>(null); const editInputRef = useRef<HTMLElement>(null); const colHeadersRef = useRef<HTMLDivElement>(null); const rowHeadersRef = useRef<HTMLDivElement>(null); const isSelectingRef = useRef(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [resizingCol, setResizingCol] = useState<number | null>(null); const resizeStartX = useRef(0); const resizeStartWidth = useRef(0); const [resizingRow, setResizingRow] = useState<number | null>(null); const resizeStartY = useRef(0); const resizeStartHeight = useRef(0); const [isFillDragging, setIsFillDragging] = useState(false); const fillSourceRef = useRef<{ row: number; col: number } | null>(null); const [scrollLeft, setScrollLeft] = useState(0); const dragAnchorRef = useRef<CellPosition | null>(null); const [viewportWidth, setViewportWidth] = useState(0); const [viewportHeight, setViewportHeight] = useState(0); const [extendedCols, setExtendedCols] = useState(0); const totalCols = TOTAL_COLS + extendedCols; const getColWidth = useCallback( (col: number) => sheet.columnWidths[col] ?? DEFAULT_COL_WIDTH, [sheet.columnWidths] ); const getBaseRowHeight = useCallback( (row: number) => sheet.rowHeights[row] ?? DEFAULT_ROW_HEIGHT, [sheet.rowHeights] ); // Compute auto-heights for rows with wrapText — skip entirely when many cells (INP optimization) const cellCount = Object.keys(sheet.cells).length; const wrapRowHeights = useMemo(() => { if (cellCount > 3000) return {}; // Skip expensive computation for large sheets const heights: Record<number, number> = {}; let wrapCount = 0; const MAX_WRAP_CELLS = 200; const MAX_ITERATIONS = 1000; let iter = 0; for (const [id, cell] of Object.entries(sheet.cells)) { if (++iter > MAX_ITERATIONS) break; if (!cell.style?.wrapText) continue; if (++wrapCount > MAX_WRAP_CELLS) break; const pos = parseCellId(id); if (!pos) continue; const text = cell.computedValue !== undefined && cell.computedValue !== '' ? String(cell.computedValue) : cell.value; if (!text) continue; const colW = sheet.columnWidths[pos.col] ?? DEFAULT_COL_WIDTH; const needed = measureWrappedHeight(text, colW, cell.style) + 4; if (!heights[pos.row] || needed > heights[pos.row]) { heights[pos.row] = needed; } } return heights; }, [sheet.cells, sheet.columnWidths]); const getRowHeight = useCallback( (row: number) => { const base = getBaseRowHeight(row); const wrap = wrapRowHeights[row]; return wrap ? Math.max(base, wrap) : base; }, [getBaseRowHeight, wrapRowHeights] ); // Compute column positions const colPositions = useMemo(() => { const positions: number[] = [0]; for (let c = 0; c < totalCols; c++) { positions.push(positions[c] + getColWidth(c)); } return positions; }, [getColWidth, totalCols]); // Compute row positions const rowPositions = useMemo(() => { const positions: number[] = [0]; for (let r = 0; r < TOTAL_ROWS; r++) { positions.push(positions[r] + getRowHeight(r)); } return positions; }, [getRowHeight]); const totalWidth = colPositions[totalCols]; const totalHeight = rowPositions[TOTAL_ROWS]; const frozenRows = sheet.frozenRows ?? 0; const frozenCols = sheet.frozenCols ?? 0; const frozenHeight = rowPositions[frozenRows] ?? 0; const frozenWidth = colPositions[frozenCols] ?? 0; const bodyRef = useRef<HTMLDivElement>(null); // Virtual scroll — reduced overscan for better INP (fewer cells to render per frame) const rowVirtualizer = useVirtualizer({ count: TOTAL_ROWS, getScrollElement: () => bodyRef.current, estimateSize: getRowHeight, overscan: 5, }); const columnVirtualizer = useVirtualizer({ count: totalCols, getScrollElement: () => bodyRef.current, estimateSize: getColWidth, overscan: 3, horizontal: true, }); const virtualRows = rowVirtualizer.getVirtualItems(); const virtualCols = columnVirtualizer.getVirtualItems(); // Visible range from virtualizer — always include frozen rows/cols (they're sticky and always visible) const visibleRange = useMemo(() => { if (virtualRows.length === 0 || virtualCols.length === 0) { return { startRow: 0, endRow: Math.min(TOTAL_ROWS, Math.max(frozenRows, frozenRows + 50)), startCol: 0, endCol: Math.min(totalCols, Math.max(frozenCols, frozenCols + 30)), }; } const vStartRow = virtualRows[0]?.index ?? frozenRows; const vEndRow = (virtualRows[virtualRows.length - 1]?.index ?? frozenRows) + 1; const vStartCol = virtualCols[0]?.index ?? frozenCols; const vEndCol = (virtualCols[virtualCols.length - 1]?.index ?? frozenCols) + 1; return { startRow: frozenRows > 0 ? 0 : Math.max(0, vStartRow), endRow: Math.min(TOTAL_ROWS, Math.max(frozenRows, vEndRow)), startCol: frozenCols > 0 ? 0 : Math.max(0, vStartCol), endCol: Math.min(totalCols, Math.max(frozenCols, vEndCol)), }; }, [virtualRows, virtualCols, frozenRows, frozenCols, totalCols]); // Notify parent of visible range changes — throttle during scroll for smooth progressive loading const visibleRangeRef = useRef(visibleRange); visibleRangeRef.current = visibleRange; const lastEmitRef = useRef(0); const pendingRef = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => { if (!onVisibleRangeChange) return; const THROTTLE_MS = 150; const now = Date.now(); const elapsed = now - lastEmitRef.current; const fire = () => { lastEmitRef.current = Date.now(); onVisibleRangeChange(visibleRangeRef.current); }; if (elapsed >= THROTTLE_MS || lastEmitRef.current === 0) { fire(); if (pendingRef.current) { clearTimeout(pendingRef.current); pendingRef.current = null; } } else if (!pendingRef.current) { pendingRef.current = setTimeout(() => { pendingRef.current = null; fire(); }, THROTTLE_MS - elapsed); } return () => { if (pendingRef.current) { clearTimeout(pendingRef.current); pendingRef.current = null; } }; }, [visibleRange, sheet.id, onVisibleRangeChange]); useEffect(() => { const body = bodyRef.current; if (!body) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { setViewportWidth(entry.contentRect.width); setViewportHeight(entry.contentRect.height); } }); observer.observe(body); return () => observer.disconnect(); }, []); const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const target = e.currentTarget; const st = target.scrollTop; const sl = target.scrollLeft; // Sync header positions immediately via DOM refs — zero-lag if (colHeadersRef.current) { colHeadersRef.current.style.transform = `translateX(${-sl}px)`; } if (rowHeadersRef.current) { rowHeadersRef.current.style.transform = `translateY(${-st}px)`; } setScrollLeft(sl); }, []); // Extend columns when scrolling near the right edge useEffect(() => { if (viewportWidth > 0 && scrollLeft + viewportWidth > totalWidth - 400) { setExtendedCols((prev) => prev + 50); } }, [scrollLeft, viewportWidth, totalWidth]); const extractAndStop = useCallback( (save: boolean) => { if (save && editInputRef.current) { const runs = htmlToTextRuns(editInputRef.current); const plainText = textRunsToPlainText(runs); const hasFormatting = !isPlainText(runs); onStopEditing(save, hasFormatting ? sanitizeRuns(runs) : undefined, plainText); } else { onStopEditing(save); } }, [onStopEditing], ); const mergeMap = useMemo(() => { const map = new Map<string, MergeInfo>(); for (const m of sheet.mergedCells ?? []) { const nm = normalizeRange(m); for (let r = nm.start.row; r <= nm.end.row; r++) { for (let c = nm.start.col; c <= nm.end.col; c++) { const id = cellId(r, c); if (r === nm.start.row && c === nm.start.col) { map.set(id, { isOrigin: true, range: nm }); } else { map.set(id, { isOrigin: false }); } } } } return map; }, [sheet.mergedCells]); const getMergeForCell = useCallback( (row: number, col: number): CellRange | undefined => { const info = mergeMap.get(cellId(row, col)); if (!info) return undefined; if (info.isOrigin) return info.range; for (const m of sheet.mergedCells ?? []) { const nm = normalizeRange(m); if (row >= nm.start.row && row <= nm.end.row && col >= nm.start.col && col <= nm.end.col) { return nm; } } return undefined; }, [mergeMap, sheet.mergedCells] ); // Throttle edit value updates to reduce re-renders during typing (FormulaBar still gets value on stop) const lastEditEmitRef = useRef(0); const editTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pendingEditRef = useRef<string | null>(null); const throttledOnEditValueChange = useCallback( (value: string) => { pendingEditRef.current = value; const now = Date.now(); const THROTTLE_MS = 32; const fire = () => { if (pendingEditRef.current !== null) { onEditValueChange(pendingEditRef.current); pendingEditRef.current = null; } lastEditEmitRef.current = Date.now(); }; if (now - lastEditEmitRef.current >= THROTTLE_MS) { fire(); if (editTimeoutRef.current) { clearTimeout(editTimeoutRef.current); editTimeoutRef.current = null; } } else if (!editTimeoutRef.current) { editTimeoutRef.current = setTimeout(() => { editTimeoutRef.current = null; fire(); }, THROTTLE_MS - (now - lastEditEmitRef.current)); } }, [onEditValueChange], ); // Focus the edit input synchronously before paint and place cursor at end useLayoutEffect(() => { if (!isEditing || !editInputRef.current || !selectedCell) return; const el = editInputRef.current; const id = cellId(selectedCell.row, selectedCell.col); const cellData = sheet.cells[id]; const existingPlain = cellData?.formula ? `=${cellData.formula}` : (cellData?.value || ''); const isRestoringExisting = editValue === existingPlain || editValue === ''; if (cellData?.richText?.length && isRestoringExisting) { el.innerHTML = textRunsToHtml(cellData.richText); } else { el.textContent = editValue; } el.focus(); const sel = window.getSelection(); if (sel) { const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); sel.removeAllRanges(); sel.addRange(range); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditing]); const handleCellMouseDown = useCallback( (e: React.MouseEvent, row: number, col: number) => { if (e.button !== 0) return; // Ctrl+Click opens link if ((e.ctrlKey || e.metaKey) && !e.shiftKey) { const id = cellId(row, col); const link = sheet.cells[id]?.link; if (link) { e.preventDefault(); window.open(link, '_blank', 'noopener'); return; } } // Click on a cell with comment indicator opens comments if (onOpenComments && commentCells?.has(`${sheet.id}:${row}:${col}`)) { const target = e.currentTarget as HTMLElement; const rect = target.getBoundingClientRect(); onOpenComments(row, col, rect); } if (isEditing) { extractAndStop(true); } // If clicking inside a merged cell, redirect to the origin const merge = getMergeForCell(row, col); const targetPos = merge ? merge.start : { row, col }; if (e.shiftKey && selectedCell) { onSelectRange({ start: selectedCell, end: { row, col } }); } else { // INP: direct DOM update — clear selection from ALL cells in range if (selectedRange) { const range = normalizeRange(selectedRange); const fr = sheet.frozenRows ?? 0; const fc = sheet.frozenCols ?? 0; const cleared = new Set<string>(); for (const pos of getRangeCells(range)) { const merge = getMergeForCell(pos.row, pos.col); const origin = merge ? merge.start : pos; const id = cellId(origin.row, origin.col); if (cleared.has(id)) continue; cleared.add(id); const el = containerRef.current?.querySelector(`[data-cell="${id}"]`) as HTMLElement | null; if (el) { el.classList.remove('selected-optimistic', 'has-fill-handle'); el.querySelector('.cell-fill-handle')?.setAttribute('style', 'display:none'); const r = origin.row; const c = origin.col; if (r === fr - 1 && c === fc - 1) el.style.boxShadow = '2px 2px 0 0 #bababa'; else if (r === fr - 1) el.style.boxShadow = '0 2px 0 0 #bababa'; else if (c === fc - 1) el.style.boxShadow = '2px 0 0 0 #bababa'; else el.style.boxShadow = ''; } } } const newEl = e.currentTarget as HTMLElement; newEl.classList.add('selected-optimistic'); newEl.style.boxShadow = 'inset 0 0 0 9999px rgba(46, 115, 252, 0.08), inset 0 2px 0 0 var(--border-selected), inset 0 -2px 0 0 var(--border-selected), inset 2px 0 0 0 var(--border-selected), inset -2px 0 0 0 var(--border-selected)'; dragAnchorRef.current = targetPos; isSelectingRef.current = true; const rangeToClear = selectedRange ? normalizeRange(selectedRange) : null; const clearFillHandles = () => { if (!rangeToClear) return; const cleared = new Set<string>(); for (const pos of getRangeCells(rangeToClear)) { const m = getMergeForCell(pos.row, pos.col); const origin = m ? m.start : pos; const id = cellId(origin.row, origin.col); if (cleared.has(id)) continue; cleared.add(id); const el = containerRef.current?.querySelector(`[data-cell="${id}"]`) as HTMLElement | null; if (el) { el.classList.remove('has-fill-handle'); el.querySelector('.cell-fill-handle')?.setAttribute('style', 'display:none'); } } }; if (isEditing) { requestAnimationFrame(() => { clearFillHandles(); }); } setTimeout(() => { clearFillHandles(); onSelectCell(targetPos); if (merge) onSelectRange(merge); }, 0); } }, [isEditing, selectedCell, selectedRange, onSelectCell, onSelectRange, extractAndStop, sheet.cells, sheet.frozenRows, sheet.frozenCols, onOpenComments, commentCells, getMergeForCell] ); const pendingRangeRef = useRef<CellRange | null>(null); const rangeRafRef = useRef<number | null>(null); const handleCellMouseEnter = useCallback( (row: number, col: number) => { const anchor = dragAnchorRef.current ?? selectedCell; if (isSelectingRef.current && anchor) { const range: CellRange = { start: anchor, end: { row, col } }; pendingRangeRef.current = range; if (rangeRafRef.current == null) { rangeRafRef.current = requestAnimationFrame(() => { rangeRafRef.current = null; const r = pendingRangeRef.current; if (r) { pendingRangeRef.current = null; onSelectRange(r); } }); } } }, [selectedCell, onSelectRange] ); useEffect(() => { const handleMouseUp = () => { if (rangeRafRef.current != null) { cancelAnimationFrame(rangeRafRef.current); rangeRafRef.current = null; } const r = pendingRangeRef.current; if (r) { pendingRangeRef.current = null; onSelectRange(r); } dragAnchorRef.current = null; isSelectingRef.current = false; }; document.addEventListener('mouseup', handleMouseUp); return () => document.removeEventListener('mouseup', handleMouseUp); }, [onSelectRange]); const handleFillHandleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!selectedRange || !onFillDates || readOnly) return; const range = normalizeRange(selectedRange); const srcId = cellId(range.start.row, range.start.col); const srcCell = sheet.cells[srcId]; const srcVal = srcCell?.computedValue ?? srcCell?.value ?? ''; if (!parseCellDate(srcVal)) return; setIsFillDragging(true); fillSourceRef.current = { row: range.start.row, col: range.start.col }; }, [selectedRange, onFillDates, readOnly, sheet.cells] ); const fillRangeRafRef = useRef<number | null>(null); const fillRangePendingRef = useRef<CellRange | null>(null); const handleFillMouseMove = useCallback( (e: MouseEvent) => { if (!isFillDragging || !selectedCell || !bodyRef.current || !onSelectRange) return; const body = bodyRef.current; const rect = body.getBoundingClientRect(); const x = e.clientX - rect.left + body.scrollLeft; const y = e.clientY - rect.top + body.scrollTop; let endCol = 0; while (endCol < totalCols && colPositions[endCol + 1] < x) endCol++; let endRow = 0; while (endRow < TOTAL_ROWS && rowPositions[endRow + 1] < y) endRow++; endCol = Math.max(0, Math.min(totalCols - 1, endCol)); endRow = Math.max(0, Math.min(TOTAL_ROWS - 1, endRow)); const src = fillSourceRef.current ?? selectedCell; const newEnd = { row: endRow, col: endCol }; if (newEnd.row >= src.row && newEnd.col >= src.col) { fillRangePendingRef.current = { start: src, end: newEnd }; if (fillRangeRafRef.current == null) { fillRangeRafRef.current = requestAnimationFrame(() => { fillRangeRafRef.current = null; const r = fillRangePendingRef.current; if (r) { fillRangePendingRef.current = null; onSelectRange(r); } }); } } }, [isFillDragging, selectedCell, colPositions, rowPositions, totalCols, onSelectRange] ); const handleFillMouseUp = useCallback(() => { if (!isFillDragging) return; setIsFillDragging(false); if (fillRangeRafRef.current != null) { cancelAnimationFrame(fillRangeRafRef.current); fillRangeRafRef.current = null; } const pendingRange = fillRangePendingRef.current; if (pendingRange && onSelectRange) { fillRangePendingRef.current = null; onSelectRange(pendingRange); } const src = fillSourceRef.current; fillSourceRef.current = null; const rangeToFill = pendingRange ?? selectedRange; if (src && rangeToFill && onFillDates) { const range = normalizeRange(rangeToFill); if (range.start.row !== range.end.row || range.start.col !== range.end.col) { onFillDates(src.row, src.col, range); } } }, [isFillDragging, selectedRange, onFillDates, onSelectRange]); useEffect(() => { if (!isFillDragging) return; document.addEventListener('mousemove', handleFillMouseMove); document.addEventListener('mouseup', handleFillMouseUp); return () => { document.removeEventListener('mousemove', handleFillMouseMove); document.removeEventListener('mouseup', handleFillMouseUp); }; }, [isFillDragging, handleFillMouseMove, handleFillMouseUp]); const handleCellDoubleClick = useCallback(() => { if (readOnly) return; onStartEditing(); }, [onStartEditing, readOnly]); const handleEditKeyDown = useCallback( (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); extractAndStop(true); if (selectedCell && selectedCell.row < TOTAL_ROWS - 1) { onSelectCell({ row: selectedCell.row + 1, col: selectedCell.col }); } } else if (e.key === 'Tab') { e.preventDefault(); extractAndStop(true); if (selectedCell && selectedCell.col < totalCols - 1) { onSelectCell({ row: selectedCell.row, col: selectedCell.col + 1 }); } } else if (e.key === 'Escape') { e.preventDefault(); extractAndStop(false); } // Shift+Enter inserts a newline (<br> in contentEditable) }, [extractAndStop, onSelectCell, selectedCell] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isEditing) return; const { key, ctrlKey, metaKey } = e; const mod = ctrlKey || metaKey; if (!selectedCell) return; switch (key) { case 'ArrowUp': e.preventDefault(); if (selectedCell.row > 0) { const newPos = { row: selectedCell.row - 1, col: selectedCell.col }; onSelectCell(newPos); } break; case 'ArrowDown': e.preventDefault(); if (selectedCell.row < TOTAL_ROWS - 1) { const newPos = { row: selectedCell.row + 1, col: selectedCell.col }; onSelectCell(newPos); } break; case 'ArrowLeft': e.preventDefault(); if (selectedCell.col > 0) { const newPos = { row: selectedCell.row, col: selectedCell.col - 1 }; onSelectCell(newPos); } break; case 'ArrowRight': e.preventDefault(); if (selectedCell.col < totalCols - 1) { const newPos = { row: selectedCell.row, col: selectedCell.col + 1 }; onSelectCell(newPos); } break; case 'Tab': e.preventDefault(); if (selectedCell.col < totalCols - 1) { onSelectCell({ row: selectedCell.row, col: selectedCell.col + 1 }); } break; case 'Escape': e.preventDefault(); if (clipboard) onClearClipboard(); break; case 'Enter': e.preventDefault(); if (!readOnly) onStartEditing(); break; case 'Delete': case 'Backspace': e.preventDefault(); if (!readOnly) onDeleteContent(); break; case 'F2': e.preventDefault(); if (!readOnly) onStartEditing(); break; default: if (mod && key === 'c') { e.preventDefault(); onCopy(false); } else if (mod && key === 'x') { if (!readOnly) { e.preventDefault(); onCopy(true); } } else if (mod && key === 'v') { if (!readOnly) { e.preventDefault(); onPaste(); } } else if (key.length === 1 && !mod) { if (!readOnly) { e.preventDefault(); onStartEditing(key); } } break; } }, [isEditing, selectedCell, totalCols, onSelectCell, onStartEditing, onDeleteContent, onCopy, onPaste, onClearClipboard, clipboard, readOnly] ); const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY }); }, [] ); const normalizedRange = selectedRange ? normalizeRange(selectedRange) : null; // Expand selection to fully include any partially-overlapping merged cells const expandedRange = useMemo(() => { if (!normalizedRange) return null; const merges = sheet.mergedCells ?? []; if (merges.length === 0) return normalizedRange; let sr = normalizedRange.start.row, sc = normalizedRange.start.col; let er = normalizedRange.end.row, ec = normalizedRange.end.col; let changed = true; while (changed) { changed = false; for (const m of merges) { const nm = normalizeRange(m); if (nm.start.row <= er && nm.end.row >= sr && nm.start.col <= ec && nm.end.col >= sc) { const nsr = Math.min(sr, nm.start.row); const nsc = Math.min(sc, nm.start.col); const ner = Math.max(er, nm.end.row); const nec = Math.max(ec, nm.end.col); if (nsr !== sr || nsc !== sc || ner !== er || nec !== ec) { sr = nsr; sc = nsc; er = ner; ec = nec; changed = true; } } } } return { start: { row: sr, col: sc }, end: { row: er, col: ec } }; }, [normalizedRange, sheet.mergedCells]); const selectedCellData = selectedCell ? sheet.cells[cellId(selectedCell.row, selectedCell.col)] : undefined; // Expand freeze boundary to avoid splitting merged cells (iterates until stable) const getEffectiveFreezeRows = useCallback((count: number): number => { let effective = count; let changed = true; while (changed) { changed = false; for (const m of sheet.mergedCells ?? []) { const nm = normalizeRange(m); if (nm.start.row < effective && nm.end.row >= effective) { effective = nm.end.row + 1; changed = true; } } } return effective; }, [sheet.mergedCells]); const getEffectiveFreezeCols = useCallback((count: number): number => { let effective = count; let changed = true; while (changed) { changed = false; for (const m of sheet.mergedCells ?? []) { const nm = normalizeRange(m); if (nm.start.col < effective && nm.end.col >= effective) { effective = nm.end.col + 1; changed = true; } } } return effective; }, [sheet.mergedCells]); const contextMenuItems: ContextMenuItem[] = selectedCell ? (() => { if (readOnly) { const items: ContextMenuItem[] = [ { label: 'Копировать', action: () => onCopy(false) }, ]; if (onOpenComments) { items.push({ label: '', action: () => {}, separator: true }); items.push({ label: 'Комментарий', action: () => { const el = bodyRef.current?.querySelector(`[data-cell="${cellId(selectedCell.row, selectedCell.col)}"]`) as HTMLElement | null; const rect = el?.getBoundingClientRect() ?? new DOMRect(0, 0, 0, 0); onOpenComments(selectedCell.row, selectedCell.col, rect); } }); } return items; } const effRows = getEffectiveFreezeRows(selectedCell.row + 1); const effCols = getEffectiveFreezeCols(selectedCell.col + 1); const rowLabel = effRows === 1 ? 'строку' : effRows < 5 ? 'строки' : 'строк'; const colLabel = effCols === 1 ? 'столбец' : effCols < 5 ? 'столбца' : 'столбцов'; return [ { label: 'Вырезать', action: () => onCopy(true) }, { label: 'Копировать', action: () => onCopy(false) }, { label: 'Вставить', action: () => onPaste() }, { label: '', action: () => {}, separator: true }, { label: 'Удалить содержимое', action: () => onDeleteContent() }, { label: '', action: () => {}, separator: true }, { label: selectedCellData?.link ? 'Редактировать ссылку' : 'Вставить ссылку', action: () => onInsertLink(), }, ...(selectedCellData?.link ? [ { label: 'Открыть ссылку', action: () => window.open(selectedCellData.link!, '_blank', 'noopener'), }, { label: 'Удалить ссылку', action: () => onRemoveLink(), }, ] : []), ...((() => { const cellMerge = getMergeForCell(selectedCell.row, selectedCell.col); if (cellMerge) { return [ { label: '', action: () => {}, separator: true }, { label: 'Отменить объединение', action: () => onUnmergeCells() }, ]; } const nr = normalizedRange; const isMultiCell = nr && (nr.start.row !== nr.end.row || nr.start.col !== nr.end.col); return isMultiCell ? [ { label: '', action: () => {}, separator: true }, { label: 'Объединить ячейки', action: () => onMergeCells() }, ] : []; })()), { label: '', action: () => {}, separator: true }, { label: 'Вставить строку сверху', action: () => onInsertRow(selectedCell.row), }, { label: 'Вставить столбец слева', action: () => onInsertColumn(selectedCell.col), }, { label: '', action: () => {}, separator: true }, { label: 'Удалить строку', action: () => onDeleteRow(selectedCell.row), }, { label: 'Удалить столбец', action: () => onDeleteColumn(selectedCell.col), }, { label: '', action: () => {}, separator: true }, { label: `Закрепить ${effRows} ${rowLabel}`, action: () => onSetFrozenRows(effRows), }, { label: `Закрепить ${effCols} ${colLabel}`, action: () => onSetFrozenCols(effCols), }, ...(frozenRows > 0 || frozenCols > 0 ? [{ label: 'Снять закрепление', action: () => { onSetFrozenRows(0); onSetFrozenCols(0); }, }] : []), ...(onOpenComments ? [ { label: '', action: () => {}, separator: true }, { label: 'Комментарий', action: () => { const el = bodyRef.current?.querySelector(`[data-cell="${cellId(selectedCell.row, selectedCell.col)}"]`) as HTMLElement | null; const rect = el?.getBoundingClientRect() ?? new DOMRect(0, 0, 0, 0); onOpenComments(selectedCell.row, selectedCell.col, rect); } }, ] : []), ]; })() : []; // Column resize handlers const handleResizeMouseDown = useCallback( (e: React.MouseEvent, col: number) => { if (readOnly) return; e.preventDefault(); e.stopPropagation(); setResizingCol(col); resizeStartX.current = e.clientX; resizeStartWidth.current = getColWidth(col); }, [getColWidth, readOnly] ); useEffect(() => { if (resizingCol === null) return; const handleMouseMove = (e: MouseEvent) => { const diff = e.clientX - resizeStartX.current; const newWidth = Math.max(40, resizeStartWidth.current + diff); onSetColumnWidth(resizingCol, newWidth); }; const handleMouseUp = () => { setResizingCol(null); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [resizingCol, onSetColumnWidth]); // Row resize handlers const handleRowResizeMouseDown = useCallback( (e: React.MouseEvent, row: number) => { if (readOnly) return; e.preventDefault(); e.stopPropagation(); setResizingRow(row); resizeStartY.current = e.clientY; resizeStartHeight.current = getRowHeight(row); }, [getRowHeight, readOnly] ); useEffect(() => { if (resizingRow === null) return; const handleMouseMove = (e: MouseEvent) => { const diff = e.clientY - resizeStartY.current; const newHeight = Math.max(16, resizeStartHeight.current + diff); onSetRowHeight(resizingRow, newHeight); }; const handleMouseUp = () => { setResizingRow(null); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [resizingRow, onSetRowHeight]); const getCellDisplayValue = (cellData: CellData | undefined): string => { if (!cellData) return ''; if (cellData.computedValue !== undefined && cellData.computedValue !== '') { return formatCellValue(cellData.computedValue, cellData.style?.format); } return formatCellValue(cellData.value, cellData.style?.format); }; // Ensure selected cell is scrolled into view — deferred to next frame for INP (paint selection first) const lastScrolledCellRef = useRef<{ row: number; col: number } | null>(null); useEffect(() => { const cell = selectedCell; if (!cell || !bodyRef.current) return; const prev = lastScrolledCellRef.current; const cellChanged = !prev || prev.row !== cell.row || prev.col !== cell.col; if (!cellChanged) return; lastScrolledCellRef.current = { row: cell.row, col: cell.col }; const rafId = requestAnimationFrame(() => { const body = bodyRef.current; if (!body) return; const cellLeft = colPositions[cell.col]; const cellRight = cellLeft + getColWidth(cell.col); const cellTop = rowPositions[cell.row]; const cellBottom = cellTop + getRowHeight(cell.row); const viewLeft = body.scrollLeft; const viewRight = viewLeft + viewportWidth; const viewTop = body.scrollTop; const viewBottom = viewTop + viewportHeight; // Only adjust axes for non-frozen cells; frozen cells are always visible via sticky if (cell.col >= frozenCols) { if (cellLeft < viewLeft + frozenWidth) body.scrollLeft = cellLeft - frozenWidth; else if (cellRight > viewRight) body.scrollLeft = cellRight - viewportWidth; } if (cell.row >= frozenRows) { if (cellTop < viewTop + frozenHeight) body.scrollTop = cellTop - frozenHeight; else if (cellBottom > viewBottom) body.scrollTop = cellBottom - viewportHeight; } }); return () => cancelAnimationFrame(rafId); }, [selectedCell, colPositions, rowPositions, getColWidth, getRowHeight, viewportWidth, viewportHeight, frozenRows, frozenCols, frozenWidth, frozenHeight]); const clipboardRange = clipboard?.range ? normalizeRange(clipboard.range) : null; const renderRichText = (runs: TextRun[]) => { return runs.map((run, i) => { const spanStyle: React.CSSProperties = {}; if (run.bold) spanStyle.fontWeight = 'bold'; if (run.italic) spanStyle.fontStyle = 'italic'; if (run.underline) spanStyle.textDecoration = 'underline'; if (run.color) spanStyle.color = run.color; const parts = run.text.split('\n'); const content = parts.map((line, j) => j < parts.length - 1 ? ( <React.Fragment key={j}>{line}<br /></React.Fragment> ) : ( <React.Fragment key={j}>{line}</React.Fragment> ), ); if (run.link) { return ( <a key={i} href={run.link} style={spanStyle} className="cell-rich-link" onClick={(e) => { if (e.ctrlKey || e.metaKey) { window.open(run.link!, '_blank', 'noopener'); } e.preventDefault(); }} > {content} </a> ); } return <span key={i} style={spanStyle}>{content}</span>; }); }; const renderCells = () => { const normal: React.ReactNode[] = []; const frozenTopCells: React.ReactNode[] = []; const frozenLeftCells: React.ReactNode[] = []; const frozenCornerCells: React.ReactNode[] = []; const rendered = new Set<string>(); const renderCell = (row: number, col: number, target: React.ReactNode[], xOffset: number, yOffset: number) => { const id = cellId(row, col); if (rendered.has(id)) return; rendered.add(id); const mergeInfo = mergeMap.get(id); if (mergeInfo && !mergeInfo.isOrigin) return; const cellData = sheet.cells[id]; const isSelected = selectedCell?.row === row && selectedCell?.col === col; const inClipboard = clipboardRange && isCellInRange({ row, col }, clipboardRange); const x = colPositions[col]; const y = rowPositions[row]; let w: number; let h: number; let mergeEndCol = col; let mergeEndRow = row; if (mergeInfo?.isOrigin) { const mr = mergeInfo.range; mergeEndCol = mr.end.col; mergeEndRow = mr.end.row; w = colPositions[mr.end.col + 1] - x; h = rowPositions[mr.end.row + 1] - y; } else { w = getColWidth(col); h = getRowHeight(row); } // Use expandedRange (includes full merged cells) for visual highlighting const er = expandedRange; const inRange = er ? row <= er.end.row && mergeEndRow >= er.start.row && col <= er.end.col && mergeEndCol >= er.start.col : false; const isMultiCellRange = er && (er.start.row !== er.end.row || er.start.col !== er.end.col); const effectiveRange = isMultiCellRange ? er : (isSelected ? { start: { row, col }, end: { row: mergeEndRow, col: mergeEndCol } } : null); const showBorder = !!(effectiveRange && (isSelected || inRange)); const rangeTop = showBorder && row === effectiveRange!.start.row; const clipboardTop = inClipboard && row === clipboardRange!.start.row; const clipboardBottom = inClipboard && mergeEndRow === clipboardRange!.end.row; const clipboardLeft = inClipboard && col === clipboardRange!.start.col; const clipboardRight = inClipboard && mergeEndCol === clipboardRange!.end.col; const rangeBottom = showBorder && mergeEndRow === effectiveRange!.end.row; const rangeLeft = showBorder && col === effectiveRange!.start.col; const rangeRight = showBorder && mergeEndCol === effectiveRange!.end.col; const isFrozenRow = row < frozenRows; const isFrozenCol = col < frozenCols; const isFrozen = isFrozenRow || isFrozenCol; const selEnd = normalizedRange?.end; const srcCellForFill = normalizedRange ? sheet.cells[cellId(normalizedRange.start.row, normalizedRange.start.col)] : undefined; const srcValForFill = srcCellForFill?.computedValue ?? srcCellForFill?.value ?? ''; const hasDateForFill = parseCellDate(srcValForFill) != null; const isBottomRightOfSelection = selEnd && row <= selEnd.row && mergeEndRow >= selEnd.row && col <= selEnd.col && mergeEndCol >= selEnd.col; const hasValue = srcValForFill !== undefined && srcValForFill !== null && String(srcValForFill).trim() !== ''; const isFillHandleCell = isBottomRightOfSelection && !readOnly && onFillDates && hasValue && hasDateForFill; const style = cellData?.style; const fmt = (b: { width?: number; style?: string; color: string }) => `${b.width ?? 1}px ${b.style ?? 'solid'} ${b.color}`; const borderCss: React.CSSProperties = {}; const bottomRow = mergeEndRow; const cellBelowStyle = bottomRow < TOTAL_ROWS - 1 ? sheet.cells[cellId(bottomRow + 1, col)]?.style : undefined; if (style?.borderBottom) { borderCss.borderBottom = fmt(style.borderBottom); } else if (cellBelowStyle?.borderTop) { borderCss.borderBottom = fmt(cellBelowStyle.borderTop); } const rightCol = mergeEndCol; const cellRightStyle = rightCol < totalCols - 1 ? sheet.cells[cellId(row, rightCol + 1)]?.style : undefined; if (style?.borderRight) { borderCss.borderRight = fmt(style.borderRight); } else if (cellRightStyle?.borderLeft) { borderCss.borderRight = fmt(cellRightStyle.borderLeft); } if (row === 0 && style?.borderTop) { borderCss.borderTop = fmt(style.borderTop); } if (col === 0 && style?.borderLeft) { borderCss.borderLeft = fmt(style.borderLeft); } const isWrapped = !!style?.wrapText; const isCellHighlighted = isSelected || inRange; // When clipboard is set, show only dashed border (not solid selection) const showSelection = isCellHighlighted && !inClipboard; // Build inset box-shadow: selection overlay + range border (only outer perimeter) const shadows: string[] = []; if (showSelection) shadows.push('inset 0 0 0 9999px rgba(46, 115, 252, 0.08)'); if (showSelection && rangeTop) shadows.push('inset 0 2px 0 0 var(--border-selected)'); if (showSelection && rangeBottom) shadows.push('inset 0 -2px 0 0 var(--border-selected)'); if (showSelection && rangeLeft) shadows.push('inset 2px 0 0 0 var(--border-selected)'); if (showSelection && rangeRight) shadows.push('inset -2px 0 0 0 var(--border-selected)'); // Clipboard: only outer border (dashed), light overlay if (inClipboard) shadows.push('inset 0 0 0 9999px rgba(26, 115, 232, 0.04)'); // Other users' selections (only for matching tab) const tabId = sheet.id === 'default' ? 'default' : sheet.id; const otherUserOverlays: { color: string; username: string }[] = []; const otherUserBorders: React.CSSProperties[] = []; for (const sel of Object.values(otherUsersSelections)) { if (sel.tabId !== tabId) continue; const r = normalizeRange(sel.selectedRange); const inOtherRange = row <= r.end.row && mergeEndRow >= r.start.row && col <= r.end.col && mergeEndCol >= r.start.col; if (!inOtherRange) continue; const color = sel.avatarColor || '#6b7280'; const hexToRgba = (hex: string, a: number) => { if (!hex || !hex.startsWith('#')) return `rgba(100, 100, 100, ${a})`; const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16 & 255}, ${n >> 8 & 255}, ${n & 255}, ${a})`; }; otherUserOverlays.push({ color: hexToRgba(color, 0.15), username: sel.username }); const isTop = row === r.start.row; const isBottom = mergeEndRow === r.end.row; const isLeft = col === r.start.col; const isRight = mergeEndCol === r.end.col; const ob: React.CSSProperties = {}; if (isTop) ob.borderTop = `2px solid ${color}`; if (isBottom) ob.borderBottom = `2px solid ${color}`; if (isLeft) ob.borderLeft = `2px solid ${color}`; if (isRight) ob.borderRight = `2px solid ${color}`; if (Object.keys(ob).length) otherUserBorders.push(ob); } if (otherUserOverlays.length) { shadows.push(`inset 0 0 0 9999px ${otherUserOverlays[0].color}`); } const clipboardBorder = '2px dashed var(--accent)'; const clipboardBorderCss: React.CSSProperties = {}; if (clipboardTop) clipboardBorderCss.borderTop = clipboardBorder; if (clipboardBottom) clipboardBorderCss.borderBottom = clipboardBorder; if (clipboardLeft) clipboardBorderCss.borderLeft = clipboardBorder; if (clipboardRight) clipboardBorderCss.borderRight = clipboardBorder; const verticalAlignMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' } as const; const otherUserBorderCss: React.CSSProperties = {}; for (const ob of otherUserBorders) { Object.assign(otherUserBorderCss, ob); } const textOrientation = style?.textOrientation; const cellStyle: React.CSSProperties = { position: 'absolute', left: x - xOffset, top: y - yOffset, width: w, height: h, ...(style?.bold && { fontWeight: 'bold' }), ...(style?.italic && { fontStyle: 'italic' }), ...(style?.underline && { textDecoration: 'underline' }), ...(style?.color && { color: style.color }), ...(style?.backgroundColor && { backgroundColor: style.backgroundColor }), ...(style?.fontSize && { fontSize: style.fontSize }), ...(style?.textAlign && { textAlign: style.textAlign }), ...(style?.verticalAlign && { alignItems: verticalAlignMap[style.verticalAlign] }), /* vertical text: writing-mode applied to inner span, cell keeps normal flex for alignment */ ...borderCss, ...clipboardBorderCss, ...otherUserBorderCss, ...(shadows.length > 0 && { boxShadow: shadows.join(', ') }), }; const isEditingThisCell = isSelected && isEditing; let className = 'grid-cell'; if (isWrapped) className += ' wrap-text'; if (isFillHandleCell) className += ' has-fill-handle'; if (textOrientation === 'vertical') className += ' text-vertical'; if (cellData?.link) className += ' has-link'; if (mergeInfo?.isOrigin) className += ' merged'; if (isFrozen) className += ' frozen'; if (isFrozenRow && row === frozenRows - 1) className += ' freeze-border-bottom'; if (isFrozenCol && col === frozenCols - 1) className += ' freeze-border-right'; if (isEditingThisCell) className += ' editing'; const hasRichText = cellData?.richText && cellData.richText.length > 0; const cellLink = cellData?.link; const hasComment = commentCells?.has(`${sheet.id}:${row}:${col}`); const isCommentOpen = commentOpenCell?.row === row && commentOpenCell?.col === col; if (isCommentOpen) className += ' comment-highlight'; const cellContent = isEditingThisCell ? ( <span ref={editInputRef} contentEditable suppressContentEditableWarning className={textOrientation === 'vertical' ? 'cell-text-vertical-inner' : 'cell-edit-inner'} style={textOrientation === 'vertical' ? { transform: 'rotate(-90deg)' } : { display: 'block', width: '100%' }} onInput={() => { if (editInputRef.current) { const runs = htmlToTextRuns(editInputRef.current); throttledOnEditValueChange(textRunsToPlainText(runs)); } }} onKeyDown={handleEditKeyDown} onBlur={(e) => { const container = containerRef.current; if (container && container.contains(e.relatedTarget as Node)) return; extractAndStop(true); }} /> ) : textOrientation === 'vertical' ? ( <span className="cell-text-vertical"> <span className="cell-text-vertical-inner" style={{ transform: 'rotate(-90deg)' }}> {hasRichText ? ( <span className="cell-rich-text">{renderRichText(cellData!.richText!)}</span> ) : ( <span className="cell-text">{getCellDisplayValue(cellData)}</span> )} </span> </span> ) : hasRichText ? ( <span className="cell-rich-text">{renderRichText(cellData!.richText!)}</span> ) : ( <span className="cell-text">{getCellDisplayValue(cellData)}</span> ); target.push( <div key={id} data-cell={id} className={className} style={cellStyle} onMouseDown={(e) => handleCellMouseDown(e, row, col)} onMouseEnter={() => handleCellMouseEnter(row, col)} onDoubleClick={handleCellDoubleClick} onContextMenu={handleContextMenu} title={[ cellLink ? `${cellLink}\nCtrl+Click чтобы открыть` : null, otherUserOverlays.length ? `Выделено: ${otherUserOverlays.map((o) => o.username).join(', ')}` : null, ].filter(Boolean).join('\n') || undefined} > {hasComment && <div className="cell-comment-indicator" />} {isFillHandleCell && ( <div className="cell-fill-handle" onMouseDown={handleFillHandleMouseDown} title="Перетащите для автозаполнения датами" /> )} {textOrientation === 'vertical' && isEditingThisCell ? ( <span className="cell-text-vertical">{cellContent}</span> ) : ( cellContent )} </div> ); }; // 1. Frozen-corner cells — render first (no overlap with others) for (let row = 0; row < frozenRows; row++) { for (let col = 0; col < frozenCols; col++) { renderCell(row, col, frozenCornerCells, 0, 0); } } // 2. Frozen-top cells — rows 0..frozenRows-1, cols >= frozenCols for (let row = 0; row < frozenRows; row++) { for (let col = Math.max(frozenCols, visibleRange.startCol); col < visibleRange.endCol; col++) { renderCell(row, col, frozenTopCells, frozenWidth, 0); } } // 3. Frozen-left cells — rows >= frozenRows, cols 0..frozenCols-1 for (let row = Math.max(frozenRows, visibleRange.startRow); row < visibleRange.endRow; row++) { for (let col = 0; col < frozenCols; col++) { renderCell(row, col, frozenLeftCells, 0, frozenHeight); } } // 4. Normal scrollable cells — only the non-frozen area (rows >= frozenRows, cols >= frozenCols) for (let row = Math.max(frozenRows, visibleRange.startRow); row < visibleRange.endRow; row++) { for (let col = Math.max(frozenCols, visibleRange.startCol); col < visibleRange.endCol; col++) { renderCell(row, col, normal, frozenWidth, frozenHeight); } } return { normal, frozenTopCells, frozenLeftCells, frozenCornerCells }; }; const renderColumnHeaders = () => { const scrollable: React.ReactNode[] = []; const frozen: React.ReactNode[] = []; const renderColHeader = (col: number, target: React.ReactNode[]) => { const x = colPositions[col]; const w = getColWidth(col); const isColInRange = expandedRange ? col >= expandedRange.start.col && col <= expandedRange.end.col : selectedCell?.col === col; const isFrozen = col < frozenCols; target.push( <div key={`ch-${col}`} className={`col-header${isColInRange ? ' active' : ''}${isFrozen ? ' frozen' : ''}${isFrozen && col === frozenCols - 1 ? ' freeze-border-right' : ''}`} style={{ position: 'absolute', left: x, top: 0, width: w, height: COL_HEADER_HEIGHT, }} > {colToLetter(col)} <div className="col-resize-handle" onMouseDown={(e) => handleResizeMouseDown(e, col)} /> </div> ); }; for (let col = 0; col < frozenCols; col++) renderColHeader(col, frozen); for (let col = visibleRange.startCol; col < visibleRange.endCol; col++) renderColHeader(col, scrollable); return { scrollable, frozen }; }; const renderRowHeaders = () => { const scrollable: React.ReactNode[] = []; const frozen: React.ReactNode[] = []; const renderRowHeader = (row: number, target: React.ReactNode[]) => { const y = rowPositions[row]; const h = getRowHeight(row); const isRowInRange = expandedRange ? row >= expandedRange.start.row && row <= expandedRange.end.row : selectedCell?.row === row; const isFrozen = row < frozenRows; target.push( <div key={`rh-${row}`} className={`row-header${isRowInRange ? ' active' : ''}${isFrozen ? ' frozen' : ''}${isFrozen && row === frozenRows - 1 ? ' freeze-border-bottom' : ''}`} style={{ position: 'absolute', left: 0, top: y, width: ROW_HEADER_WIDTH, height: h, }} > {row + 1} <div className="row-resize-handle" onMouseDown={(e) => handleRowResizeMouseDown(e, row)} /> </div> ); }; for (let row = 0; row < frozenRows; row++) renderRowHeader(row, frozen); for (let row = visibleRange.startRow; row < visibleRange.endRow; row++) renderRowHeader(row, scrollable); return { scrollable, frozen }; }; const cellGroups = renderCells(); const colHeaders = renderColumnHeaders(); const rowHeaders = renderRowHeaders(); return ( <div ref={containerRef} className="grid-container" tabIndex={0} onKeyDown={handleKeyDown} style={{ display: 'grid', gridTemplateColumns: `${ROW_HEADER_WIDTH}px 1fr`, gridTemplateRows: `${COL_HEADER_HEIGHT}px 1fr`, position: 'relative', }} > {/* Corner — row 0 / col 0, always visible */} <div className="grid-corner" style={{ width: ROW_HEADER_WIDTH, height: COL_HEADER_HEIGHT, zIndex: 30, }} /> {/* Column headers — row 0 / col 1, clips overflow, synced via transform */} <div className="col-headers-viewport" style={{ overflow: 'hidden', position: 'relative', zIndex: 20 }}> <div ref={colHeadersRef} style={{ position: 'relative', width: totalWidth, height: COL_HEADER_HEIGHT, willChange: 'transform', }} > {colHeaders.scrollable} </div> {frozenCols > 0 && ( <div style={{ position: 'absolute', top: 0, left: 0, width: frozenWidth, height: COL_HEADER_HEIGHT, zIndex: 2, background: 'var(--bg-header, #f8f9fa)', }} > {colHeaders.frozen} </div> )} </div> {/* Row headers — row 1 / col 0, clips overflow, synced via transform */} <div className="row-headers-viewport" style={{ overflow: 'hidden', position: 'relative', zIndex: 20 }}> <div ref={rowHeadersRef} style={{ position: 'relative', width: ROW_HEADER_WIDTH, height: totalHeight, willChange: 'transform', }} > {rowHeaders.scrollable} </div> {frozenRows > 0 && ( <div style={{ position: 'absolute', top: 0, left: 0, width: ROW_HEADER_WIDTH, height: frozenHeight, zIndex: 2, background: 'var(--bg-header, #f8f9fa)', }} > {rowHeaders.frozen} </div> )} </div> {/* Grid body — row 1 / col 1, the only scrollable area */} <div ref={bodyRef} className="grid-body" style={{ overflow: 'auto', position: 'relative' }} onScroll={handleScroll} > <div style={{ display: 'flex', flexDirection: 'column', width: totalWidth }}> {/* Frozen top row — CSS sticky keeps it at top:0 (compositor-handled, zero jitter) */} {frozenRows > 0 && ( <div className="frozen-top-row" style={{ position: 'sticky', top: 0, display: 'flex', height: frozenHeight, zIndex: 5, flexShrink: 0, }} > {/* Frozen corner — sticky left + inherits sticky top */} {frozenCols > 0 && ( <div style={{ position: 'sticky', left: 0, width: frozenWidth, height: frozenHeight, zIndex: 5, flexShrink: 0, background: 'var(--bg-primary)', }} > {cellGroups.frozenCornerCells} </div> )} {/* Frozen top cells — scroll horizontally with content */} <div style={{ position: 'relative', width: totalWidth - frozenWidth, height: frozenHeight, flexShrink: 0, background: 'var(--bg-primary)', }} > {cellGroups.frozenTopCells} </div> </div> )} {/* Main content row */} <div style={{ display: 'flex', height: totalHeight - frozenHeight, flexShrink: 0 }}> {/* Frozen left column — sticky left (compositor-handled) */} {frozenCols > 0 && ( <div style={{ position: 'sticky', left: 0, width: frozenWidth, height: totalHeight - frozenHeight, zIndex: 4, flexShrink: 0, background: 'var(--bg-primary)', }} > {cellGroups.frozenLeftCells} </div> )} {/* Normal scrollable cells */} <div style={{ position: 'relative', width: totalWidth - frozenWidth, height: totalHeight - frozenHeight, flexShrink: 0, }} > {cellGroups.normal} </div> </div> </div> </div> {/* Context menu */} {contextMenu && ( <ContextMenu x={contextMenu.x} y={contextMenu.y} items={contextMenuItems} onClose={() => setContextMenu(null)} /> )} </div> ); }; export const Grid = React.memo(GridInner);