Разложили dualspreadsheet

This commit is contained in:
2025-07-20 09:56:41 +03:00
parent da7024cd08
commit 0d00245bf2
9 changed files with 893 additions and 846 deletions

View File

@@ -1,851 +1,21 @@
import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave'
import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine'
import { MergedCell } from '@/types/template'
import { produce } from 'immer'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
// Импорты из новых модулей
import { FormulaBar, Spreadsheet } from './components'
import { useDraggableDivider } from './hooks/useDraggableDivider'
import {
CSSProperties,
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { VariableSizeGrid } from 'react-window'
const ROW_COUNT = 90
const COL_COUNT = 20
const ROW_HEIGHT = 28
const SHEET_NAMES = {
report: 'L',
calculations: 'R',
} as const
type SheetType = keyof typeof SHEET_NAMES
// Добавляем массив типов листов для итерации
const SHEET_TYPES: SheetType[] = ['report', 'calculations']
// --- Helper Functions ---
const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
// Функция для преобразования адреса ячейки в координаты
const cellAddressToCoords = (address: string): { row: number; col: number } => {
const match = address.match(/^([A-Z]+)(\d+)$/)
if (!match) return { row: 0, col: 0 }
const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы
const row = parseInt(match[2]) - 1
return { row, col }
}
// Функция для проверки, является ли ячейка частью объединенной группы
const findMergedCellInfo = (
row: number,
col: number,
mergedCells: MergedCell[]
): {
isMerged: boolean
isMainCell: boolean
mergedCell?: MergedCell
spans?: { rowSpan: number; colSpan: number }
} => {
for (const merged of mergedCells) {
const fromCoords = cellAddressToCoords(merged.from)
const toCoords = cellAddressToCoords(merged.to)
// Проверяем, попадает ли текущая ячейка в диапазон объединения
if (
row >= fromCoords.row &&
row <= toCoords.row &&
col >= fromCoords.col &&
col <= toCoords.col
) {
const isMainCell = row === fromCoords.row && col === fromCoords.col
const rowSpan = toCoords.row - fromCoords.row + 1
const colSpan = toCoords.col - fromCoords.col + 1
return {
isMerged: true,
isMainCell,
mergedCell: merged,
spans: { rowSpan, colSpan },
}
}
}
return { isMerged: false, isMainCell: false }
}
// --- Хук для перетаскивания разделителя ---
const useDraggableDivider = (onDrag: (delta: number) => void) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
document.body.style.cursor = 'col-resize'
const container = el.parentElement
if (!container) return
const bounds = container.getBoundingClientRect()
const handleMouseMove = (me: MouseEvent) => {
// Получаем актуальные границы контейнера
const currentBounds = container.getBoundingClientRect()
const newLeftWidth = me.clientX - currentBounds.left
const totalWidth = currentBounds.width
// Рассчитываем процент напрямую от позиции мыши
let newLeftPercentage = newLeftWidth / totalWidth
newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage))
// Вызываем callback с новыми процентами
onDrag(newLeftPercentage)
}
const handleMouseUp = () => {
document.body.style.cursor = 'auto'
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
el.addEventListener('mousedown', handleMouseDown)
return () => {
el.removeEventListener('mousedown', handleMouseDown)
}
}, [onDrag])
return ref
}
// --- Interfaces ---
interface DualSpreadsheetProps {
templateData?: Record<string, Record<string, any>>
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
templateId?: string // Добавляем ID шаблона для сохранения
}
// Кэшированные данные ячеек для оптимизации
interface CachedCellData {
displayValue: string
rawValue: string
isModified: boolean
hasRightContent: boolean
}
// --- Child Components ---
interface CellProps {
sheetType: SheetType
rowIndex: number
colIndex: number
columnWidth: number
columnWidths: number[] // Добавляем массив всех ширин колонок
isSelected: boolean
isEditing: boolean
displayValue: string // Отображаемое значение (вычисленное)
rawValue: string // "Сырое" значение (формула)
isModified: boolean
hasRightContent: boolean // Есть ли содержимое в ячейках справа
onCellClick: (row: number, col: number) => void
onCellDoubleClick: (row: number, col: number) => void
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellRef: React.RefObject<HTMLInputElement>
style: CSSProperties // Добавляем style для react-window
mergedCellInfo?: {
// Информация об объединенных ячейках
isMerged: boolean
isMainCell: boolean
rowSpan?: number
colSpan?: number
}
}
const Cell = memo(
({
sheetType,
rowIndex,
colIndex,
columnWidth,
columnWidths,
isSelected,
isEditing,
displayValue,
rawValue,
isModified,
hasRightContent,
onCellClick,
onCellDoubleClick,
onCellValueChange,
stopEditing,
setActiveSheet,
activeCellRef,
style, // Добавляем style для react-window
mergedCellInfo,
}: CellProps) => {
// Если ячейка объединена, но не является основной, не рендерим её
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
return null
}
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${
isModified ? 'bg-yellow-400 !important' : ''
}`
// Отладка стилей для измененных ячеек
if (isModified) {
console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, {
className,
isModified,
isSelected,
displayValue,
})
}
// Для объединенных ячеек нужно рассчитать правильные размеры
let cellStyle: CSSProperties = {
...style,
overflow: 'visible',
padding: 0,
}
// Если это основная ячейка объединенной группы, расширяем её размеры
if (
mergedCellInfo?.isMainCell &&
mergedCellInfo.rowSpan &&
mergedCellInfo.colSpan
) {
const { rowSpan, colSpan } = mergedCellInfo
// Рассчитываем общую ширину для объединенной ячейки
let totalWidth = 0
for (let i = 0; i < colSpan; i++) {
const currentColIndex = colIndex + i
if (currentColIndex < columnWidths.length) {
totalWidth += columnWidths[currentColIndex]
}
}
// Рассчитываем общую высоту для объединенной ячейки
const totalHeight = ROW_HEIGHT * rowSpan
cellStyle = {
...cellStyle,
width: totalWidth,
height: totalHeight,
zIndex: 10, // Поднимаем выше обычных ячеек
backgroundColor: isSelected
? 'hsl(var(--accent))'
: isModified
? '#fef3c7'
: 'white',
}
}
// Используем обычное значение displayValue для всех ячеек, включая объединенные
const cellDisplayValue = displayValue
const handleClick = useCallback(() => {
setActiveSheet(sheetType)
onCellClick(rowIndex, colIndex)
}, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex])
const handleDoubleClick = useCallback(
() => onCellDoubleClick(rowIndex, colIndex),
[onCellDoubleClick, rowIndex, colIndex]
)
return (
<div
className={className}
style={cellStyle}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{isEditing && isSelected ? (
<input
ref={activeCellRef}
type="text"
value={rawValue} // Во время редактирования показываем текущее редактируемое значение
onChange={e => onCellValueChange(e.target.value)}
onBlur={e => {
// Проверяем, не переходит ли фокус в formula bar
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget && relatedTarget.closest('.formula-bar')) {
return // Не завершаем редактирование
}
stopEditing(true)
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
stopEditing(true)
} else if (e.key === 'Escape') {
e.preventDefault()
stopEditing(false)
}
}}
className="absolute left-0 top-0 z-20 box-border h-full w-full border-2 border-primary px-2 py-1"
/>
) : (
<div
className="relative flex h-full w-full items-center px-2"
style={{
overflow: hasRightContent ? 'hidden' : 'visible',
whiteSpace: 'nowrap',
...(isSelected
? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' }
: {}),
}}
>
<div
className="relative"
style={{
overflow: hasRightContent ? 'hidden' : 'visible',
textOverflow: hasRightContent ? 'ellipsis' : 'clip',
whiteSpace: 'nowrap',
maxWidth: hasRightContent ? `${columnWidth - 16}px` : 'none',
}}
>
{cellDisplayValue}
</div>
</div>
)}
</div>
)
}
)
interface FormulaBarProps {
sheetType: SheetType
activeSheet: SheetType
selectedCell: { row: number; col: number } | null
formulaBarValue: string
isCalculating: boolean
isEditing: boolean
onValueChange: (value: string) => void
onFocus: () => void
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
formulaBarRef: (el: HTMLInputElement | null) => void
// Обновляем props для сохранения
isSaving?: boolean
hasUnsavedChanges?: boolean
lastSaveError?: string | null
onManualSave?: () => void
onClearError?: () => void
}
const FormulaBar = memo(
({
selectedCell,
formulaBarValue,
isCalculating,
isEditing,
onValueChange,
onFocus,
onBlur,
onKeyDown,
formulaBarRef,
isSaving = false,
hasUnsavedChanges = false,
lastSaveError = null,
onManualSave,
onClearError,
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
return (
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex items-center space-x-2">
<div className="flex min-w-[50px] items-center justify-center rounded-md border bg-muted px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">
{selectedCell
? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}`
: 'A1'}
</span>
</div>
<div className="flex flex-1 items-center space-x-2">
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<input
ref={formulaBarRef}
type="text"
className={`flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
isEditing ? 'ring-2 ring-ring ring-offset-2' : ''
} ${isCalculating ? 'animate-pulse' : ''}`}
placeholder="Введите формулу или значение..."
value={formulaBarValue}
onChange={e => onValueChange(e.target.value)}
onFocus={onFocus}
onBlur={e => {
// Проверяем, не переходит ли фокус в ячейку редактирования
const relatedTarget = e.relatedTarget as HTMLElement
if (
relatedTarget &&
relatedTarget.closest('input[type="text"]')
) {
return // Не завершаем редактирование
}
onBlur(e)
}}
onKeyDown={onKeyDown}
/>
{(isCalculating || isSaving) && (
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="ml-1 text-xs">
{isSaving ? 'Сохранение...' : 'Расчет...'}
</span>
</div>
)}
{/* Ошибка сохранения */}
{lastSaveError && (
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
<svg
className="h-3 w-3 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span
className="text-xs text-destructive"
title={lastSaveError}
>
Ошибка сохранения
</span>
{onClearError && (
<button
onClick={onClearError}
className="ml-1 text-destructive/60 hover:text-destructive"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* Кнопка ручного сохранения */}
{onManualSave && (
<button
onClick={onManualSave}
disabled={isSaving}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
hasUnsavedChanges
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: lastSaveError
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-muted text-muted-foreground'
} disabled:opacity-50`}
title={
lastSaveError
? 'Повторить сохранение'
: hasUnsavedChanges
? 'Есть несохраненные изменения'
: 'Все изменения сохранены'
}
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{lastSaveError
? 'Повторить'
: hasUnsavedChanges
? 'Сохранить'
: 'Сохранено'}
</button>
)}
</div>
</div>
</div>
)
}
)
// Рендер-проп для ячеек грида
const CellRenderer = ({
columnIndex,
rowIndex,
style,
data,
}: {
columnIndex: number
rowIndex: number
style: CSSProperties
data: any
}) => {
const {
sheetType,
widths,
selectedCell,
activeSheet,
isEditing,
editingValue,
cachedData,
mergedCells,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
} = data
const isCellSelected =
selectedCell?.row === rowIndex &&
selectedCell?.col === columnIndex &&
activeSheet === sheetType
const isCellEditing = isCellSelected && isEditing
const cellKey = `${rowIndex}-${columnIndex}`
const cellData = cachedData[cellKey]
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
const displayValue = isCellEditing
? '' // Не показывать вычисленное значение во время редактирования
: cellData?.displayValue || ''
// Отладка для измененных ячеек
if (cellData?.isModified) {
console.log(
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
{
cellKey,
isModified: cellData.isModified,
displayValue,
rawValue,
}
)
}
// Получаем информацию об объединенных ячейках только для листа отчета
const mergedCellInfo =
sheetType === 'report' && mergedCells
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
: { isMerged: false, isMainCell: false }
const mergedCellProps = mergedCellInfo.isMerged
? {
isMerged: true,
isMainCell: mergedCellInfo.isMainCell,
rowSpan: mergedCellInfo.spans?.rowSpan,
colSpan: mergedCellInfo.spans?.colSpan,
// Убираем mergedValue - будем использовать актуальные данные из движка
}
: undefined
return (
<Cell
style={style}
sheetType={sheetType}
rowIndex={rowIndex}
colIndex={columnIndex}
columnWidth={widths[columnIndex]}
columnWidths={widths}
isSelected={isCellSelected}
isEditing={isCellEditing}
displayValue={displayValue}
rawValue={rawValue}
isModified={cellData?.isModified || false}
hasRightContent={cellData?.hasRightContent || false}
onCellClick={handleCellClick}
onCellDoubleClick={handleCellDoubleClick}
onCellValueChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellRef={activeCellInputRef}
mergedCellInfo={mergedCellProps}
/>
)
}
interface SpreadsheetProps {
sheetType: SheetType
columnWidths: number[]
spreadsheetWidths: { L: number; R: number }
getCachedCellData: (sheetType: SheetType) => Record<string, CachedCellData>
selectedCell: { row: number; col: number } | null
activeSheet: SheetType
isEditing: boolean
editingValue: string
handleCellClick: (row: number, col: number) => void
handleCellDoubleClick: (row: number, col: number) => void
handleCellChange: (newValue: string) => void
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellInputRef: React.RefObject<HTMLInputElement>
containerRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
handleKeyDown: (e: React.KeyboardEvent) => void
headerRefs: React.MutableRefObject<Record<SheetType, HTMLDivElement | null>>
rowHeaderRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
gridRefs: React.MutableRefObject<Record<SheetType, any>>
onGridScroll: (
sheetType: SheetType,
params: { scrollLeft: number; scrollTop: number }
) => void
handleColumnResize: (
sheetType: SheetType,
colIndex: number,
newWidth: number
) => void
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
}
// --- Spreadsheet Component ---
const Spreadsheet = ({
sheetType,
columnWidths,
spreadsheetWidths,
getCachedCellData,
selectedCell,
activeSheet,
isEditing,
editingValue,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
containerRefs,
handleKeyDown,
headerRefs,
rowHeaderRefs,
gridRefs,
onGridScroll,
handleColumnResize,
mergedCells,
}: SpreadsheetProps) => {
const widths = columnWidths
const cachedData = getCachedCellData(sheetType)
const itemData = {
sheetType,
widths,
selectedCell,
activeSheet,
isEditing,
editingValue,
cachedData,
mergedCells,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
}
const parentRef = useRef<HTMLDivElement>(null)
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const parent = parentRef.current
if (!parent) return
const resizeObserver = new ResizeObserver(() => {
const { width, height } = parent.getBoundingClientRect()
const headerHeight =
parent.querySelector('.column-headers')?.clientHeight || 0
setGridSize({ width, height: height - headerHeight })
})
resizeObserver.observe(parent)
return () => resizeObserver.disconnect()
}, [])
return (
<div
ref={parentRef}
className="flex min-w-0 flex-col overflow-hidden"
style={{
flexBasis: `${spreadsheetWidths[SHEET_NAMES[sheetType]] * 100}%`,
transition: 'none',
}}
onClick={() => setActiveSheet(sheetType)}
>
<div
ref={el => (containerRefs.current[sheetType] = el)}
className="flex h-full flex-col overflow-hidden border bg-card shadow-sm"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Spreadsheet Body */}
<div className="flex flex-1">
{/* Row Headers */}
<div className="flex-shrink-0 bg-muted">
<div className="relative flex h-7 w-10 items-center justify-center border-b border-r border-border">
{/* Компактное обозначение листа */}
<div className="absolute inset-0 flex items-end justify-end p-0.5">
<span
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
sheetType === 'report'
? 'bg-primary/70'
: 'bg-emerald-500/70'
}`}
>
{SHEET_NAMES[sheetType]}
</span>
</div>
</div>
<div
ref={el => (rowHeaderRefs.current[sheetType] = el)}
className="overflow-y-hidden"
style={{ height: gridSize.height }}
>
<div>
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<div
key={rowIndex}
className="flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium text-muted-foreground"
>
{rowIndex + 1}
</div>
))}
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
{/* Column Headers */}
<div
ref={el => (headerRefs.current[sheetType] = el)}
className="column-headers overflow-x-hidden"
>
<div className="flex">
{Array.from({ length: COL_COUNT }).map((_, i) => {
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
const startX = e.clientX
const startWidth = widths[i]
const handleMouseMove = (me: MouseEvent) => {
const newWidth = startWidth + (me.clientX - startX)
handleColumnResize(sheetType, i, newWidth)
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<div
key={i}
className="relative flex h-7 flex-shrink-0 items-center justify-center border-b border-r border-border bg-muted text-center text-xs font-medium text-muted-foreground"
style={{ width: widths[i] }}
>
{getColumnLabel(i)}
<div
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
onMouseDown={handleMouseDown}
/>
</div>
)
})}
</div>
</div>
{/* Grid */}
<div className="min-h-0 flex-1">
<VariableSizeGrid
ref={el => (gridRefs.current[sheetType] = el)}
columnCount={COL_COUNT}
rowCount={ROW_COUNT}
columnWidth={(index: number) => widths[index]}
rowHeight={() => ROW_HEIGHT}
height={gridSize.height}
width={gridSize.width}
itemData={itemData}
onScroll={e => onGridScroll(sheetType, e)}
>
{CellRenderer}
</VariableSizeGrid>
</div>
</div>
</div>
</div>
</div>
)
}
CachedCellData,
COL_COUNT,
DualSpreadsheetProps,
ROW_COUNT,
SHEET_NAMES,
SHEET_TYPES,
SheetType,
} from './types'
import { getColumnLabel } from './utils'
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
templateData = {},
@@ -1413,8 +583,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
isInitialized,
])
// --- Spreadsheet Component ---
return (
<div className="flex h-full flex-col bg-background">
<FormulaBar

View File

@@ -0,0 +1,157 @@
import { CSSProperties, memo, useCallback } from 'react'
import { CellProps, ROW_HEIGHT } from '../types'
export const Cell = memo(
({
sheetType,
rowIndex,
colIndex,
columnWidth,
columnWidths,
isSelected,
isEditing,
displayValue,
rawValue,
isModified,
hasRightContent,
onCellClick,
onCellDoubleClick,
onCellValueChange,
stopEditing,
setActiveSheet,
activeCellRef,
style, // Добавляем style для react-window
mergedCellInfo,
}: CellProps) => {
// Если ячейка объединена, но не является основной, не рендерим её
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
return null
}
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${
isModified ? 'bg-yellow-400 !important' : ''
}`
// Отладка стилей для измененных ячеек
if (isModified) {
console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, {
className,
isModified,
isSelected,
displayValue,
})
}
// Для объединенных ячеек нужно рассчитать правильные размеры
let cellStyle: CSSProperties = {
...style,
overflow: 'visible',
padding: 0,
}
// Если это основная ячейка объединенной группы, расширяем её размеры
if (
mergedCellInfo?.isMainCell &&
mergedCellInfo.rowSpan &&
mergedCellInfo.colSpan
) {
const { rowSpan, colSpan } = mergedCellInfo
// Рассчитываем общую ширину для объединенной ячейки
let totalWidth = 0
for (let i = 0; i < colSpan; i++) {
const currentColIndex = colIndex + i
if (currentColIndex < columnWidths.length) {
totalWidth += columnWidths[currentColIndex]
}
}
// Рассчитываем общую высоту для объединенной ячейки
const totalHeight = ROW_HEIGHT * rowSpan
cellStyle = {
...cellStyle,
width: totalWidth,
height: totalHeight,
zIndex: 10, // Поднимаем выше обычных ячеек
backgroundColor: isSelected
? 'hsl(var(--accent))'
: isModified
? '#fef3c7'
: 'white',
}
}
// Используем обычное значение displayValue для всех ячеек, включая объединенные
const cellDisplayValue = displayValue
const handleClick = useCallback(() => {
setActiveSheet(sheetType)
onCellClick(rowIndex, colIndex)
}, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex])
const handleDoubleClick = useCallback(
() => onCellDoubleClick(rowIndex, colIndex),
[onCellDoubleClick, rowIndex, colIndex]
)
return (
<div
className={className}
style={cellStyle}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{isEditing && isSelected ? (
<input
ref={activeCellRef}
type="text"
value={rawValue} // Во время редактирования показываем текущее редактируемое значение
onChange={e => onCellValueChange(e.target.value)}
onBlur={e => {
// Проверяем, не переходит ли фокус в formula bar
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget && relatedTarget.closest('.formula-bar')) {
return // Не завершаем редактирование
}
stopEditing(true)
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
stopEditing(true)
} else if (e.key === 'Escape') {
e.preventDefault()
stopEditing(false)
}
}}
className="absolute left-0 top-0 z-20 box-border h-full w-full border-2 border-primary px-2 py-1"
/>
) : (
<div
className="relative flex h-full w-full items-center px-2"
style={{
overflow: hasRightContent ? 'hidden' : 'visible',
whiteSpace: 'nowrap',
...(isSelected
? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' }
: {}),
}}
>
<div
className="relative"
style={{
overflow: hasRightContent ? 'hidden' : 'visible',
textOverflow: hasRightContent ? 'ellipsis' : 'clip',
whiteSpace: 'nowrap',
maxWidth: hasRightContent ? `${columnWidth - 16}px` : 'none',
}}
>
{cellDisplayValue}
</div>
</div>
)}
</div>
)
}
)

View File

@@ -0,0 +1,95 @@
import { CellRendererProps } from '../types'
import { findMergedCellInfo } from '../utils'
import { Cell } from './Cell'
// Рендер-проп для ячеек грида
export const CellRenderer = ({
columnIndex,
rowIndex,
style,
data,
}: CellRendererProps) => {
const {
sheetType,
widths,
selectedCell,
activeSheet,
isEditing,
editingValue,
cachedData,
mergedCells,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
} = data
const isCellSelected =
selectedCell?.row === rowIndex &&
selectedCell?.col === columnIndex &&
activeSheet === sheetType
const isCellEditing = isCellSelected && isEditing
const cellKey = `${rowIndex}-${columnIndex}`
const cellData = cachedData[cellKey]
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
const displayValue = isCellEditing
? '' // Не показывать вычисленное значение во время редактирования
: cellData?.displayValue || ''
// Отладка для измененных ячеек
if (cellData?.isModified) {
console.log(
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
{
cellKey,
isModified: cellData.isModified,
displayValue,
rawValue,
}
)
}
// Получаем информацию об объединенных ячейках только для листа отчета
const mergedCellInfo =
sheetType === 'report' && mergedCells
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
: { isMerged: false, isMainCell: false }
const mergedCellProps = mergedCellInfo.isMerged
? {
isMerged: true,
isMainCell: mergedCellInfo.isMainCell,
rowSpan: mergedCellInfo.spans?.rowSpan,
colSpan: mergedCellInfo.spans?.colSpan,
// Убираем mergedValue - будем использовать актуальные данные из движка
}
: undefined
return (
<Cell
style={style}
sheetType={sheetType}
rowIndex={rowIndex}
colIndex={columnIndex}
columnWidth={widths[columnIndex]}
columnWidths={widths}
isSelected={isCellSelected}
isEditing={isCellEditing}
displayValue={displayValue}
rawValue={rawValue}
isModified={cellData?.isModified || false}
hasRightContent={cellData?.hasRightContent || false}
onCellClick={handleCellClick}
onCellDoubleClick={handleCellDoubleClick}
onCellValueChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellRef={activeCellInputRef}
mergedCellInfo={mergedCellProps}
/>
)
}

View File

@@ -0,0 +1,186 @@
import { memo } from 'react'
import { FormulaBarProps } from '../types'
import { getColumnLabel } from '../utils'
export const FormulaBar = memo(
({
selectedCell,
formulaBarValue,
isCalculating,
isEditing,
onValueChange,
onFocus,
onBlur,
onKeyDown,
formulaBarRef,
isSaving = false,
hasUnsavedChanges = false,
lastSaveError = null,
onManualSave,
onClearError,
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
return (
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex items-center space-x-2">
<div className="flex min-w-[50px] items-center justify-center rounded-md border bg-muted px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">
{selectedCell
? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}`
: 'A1'}
</span>
</div>
<div className="flex flex-1 items-center space-x-2">
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<input
ref={formulaBarRef}
type="text"
className={`flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
isEditing ? 'ring-2 ring-ring ring-offset-2' : ''
} ${isCalculating ? 'animate-pulse' : ''}`}
placeholder="Введите формулу или значение..."
value={formulaBarValue}
onChange={e => onValueChange(e.target.value)}
onFocus={onFocus}
onBlur={e => {
// Проверяем, не переходит ли фокус в ячейку редактирования
const relatedTarget = e.relatedTarget as HTMLElement
if (
relatedTarget &&
relatedTarget.closest('input[type="text"]')
) {
return // Не завершаем редактирование
}
onBlur(e)
}}
onKeyDown={onKeyDown}
/>
{(isCalculating || isSaving) && (
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="ml-1 text-xs">
{isSaving ? 'Сохранение...' : 'Расчет...'}
</span>
</div>
)}
{/* Ошибка сохранения */}
{lastSaveError && (
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
<svg
className="h-3 w-3 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span
className="text-xs text-destructive"
title={lastSaveError}
>
Ошибка сохранения
</span>
{onClearError && (
<button
onClick={onClearError}
className="ml-1 text-destructive/60 hover:text-destructive"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
)}
{/* Кнопка ручного сохранения */}
{onManualSave && (
<button
onClick={onManualSave}
disabled={isSaving}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
hasUnsavedChanges
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: lastSaveError
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-muted text-muted-foreground'
} disabled:opacity-50`}
title={
lastSaveError
? 'Повторить сохранение'
: hasUnsavedChanges
? 'Есть несохраненные изменения'
: 'Все изменения сохранены'
}
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
{lastSaveError
? 'Повторить'
: hasUnsavedChanges
? 'Сохранить'
: 'Сохранено'}
</button>
)}
</div>
</div>
</div>
)
}
)

View File

@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'react'
import { VariableSizeGrid } from 'react-window'
import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
import { getColumnLabel } from '../utils'
import { CellRenderer } from './CellRenderer'
// --- Spreadsheet Component ---
export const Spreadsheet = ({
sheetType,
columnWidths,
spreadsheetWidths,
getCachedCellData,
selectedCell,
activeSheet,
isEditing,
editingValue,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
containerRefs,
handleKeyDown,
headerRefs,
rowHeaderRefs,
gridRefs,
onGridScroll,
handleColumnResize,
mergedCells,
}: SpreadsheetProps) => {
const widths = columnWidths
const cachedData = getCachedCellData(sheetType)
const itemData = {
sheetType,
widths,
selectedCell,
activeSheet,
isEditing,
editingValue,
cachedData,
mergedCells,
handleCellClick,
handleCellDoubleClick,
handleCellChange,
stopEditing,
setActiveSheet,
activeCellInputRef,
}
const parentRef = useRef<HTMLDivElement>(null)
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const parent = parentRef.current
if (!parent) return
const resizeObserver = new ResizeObserver(() => {
const { width, height } = parent.getBoundingClientRect()
const headerHeight =
parent.querySelector('.column-headers')?.clientHeight || 0
setGridSize({ width, height: height - headerHeight })
})
resizeObserver.observe(parent)
return () => resizeObserver.disconnect()
}, [])
return (
<div
ref={parentRef}
className="flex min-w-0 flex-col overflow-hidden"
style={{
flexBasis: `${spreadsheetWidths[SHEET_NAMES[sheetType]] * 100}%`,
transition: 'none',
}}
onClick={() => setActiveSheet(sheetType)}
>
<div
ref={el => (containerRefs.current[sheetType] = el)}
className="flex h-full flex-col overflow-hidden border bg-card shadow-sm"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{/* Spreadsheet Body */}
<div className="flex flex-1">
{/* Row Headers */}
<div className="flex-shrink-0 bg-muted">
<div className="relative flex h-7 w-10 items-center justify-center border-b border-r border-border">
{/* Компактное обозначение листа */}
<div className="absolute inset-0 flex items-end justify-end p-0.5">
<span
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
sheetType === 'report'
? 'bg-primary/70'
: 'bg-emerald-500/70'
}`}
>
{SHEET_NAMES[sheetType]}
</span>
</div>
</div>
<div
ref={el => (rowHeaderRefs.current[sheetType] = el)}
className="overflow-y-hidden"
style={{ height: gridSize.height }}
>
<div>
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<div
key={rowIndex}
className="flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium text-muted-foreground"
>
{rowIndex + 1}
</div>
))}
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
{/* Column Headers */}
<div
ref={el => (headerRefs.current[sheetType] = el)}
className="column-headers overflow-x-hidden"
>
<div className="flex">
{Array.from({ length: COL_COUNT }).map((_, i) => {
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
const startX = e.clientX
const startWidth = widths[i]
const handleMouseMove = (me: MouseEvent) => {
const newWidth = startWidth + (me.clientX - startX)
handleColumnResize(sheetType, i, newWidth)
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return (
<div
key={i}
className="relative flex h-7 flex-shrink-0 items-center justify-center border-b border-r border-border bg-muted text-center text-xs font-medium text-muted-foreground"
style={{ width: widths[i] }}
>
{getColumnLabel(i)}
<div
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
onMouseDown={handleMouseDown}
/>
</div>
)
})}
</div>
</div>
{/* Grid */}
<div className="min-h-0 flex-1">
<VariableSizeGrid
ref={(el: VariableSizeGrid | null) =>
(gridRefs.current[sheetType] = el)
}
columnCount={COL_COUNT}
rowCount={ROW_COUNT}
columnWidth={(index: number) => widths[index]}
rowHeight={() => 28}
height={gridSize.height}
width={gridSize.width}
itemData={itemData}
onScroll={e => onGridScroll(sheetType, e)}
>
{CellRenderer}
</VariableSizeGrid>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { Cell } from './Cell'
export { CellRenderer } from './CellRenderer'
export { FormulaBar } from './FormulaBar'
export { Spreadsheet } from './Spreadsheet'

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react'
// --- Хук для перетаскивания разделителя ---
export const useDraggableDivider = (onDrag: (delta: number) => void) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
document.body.style.cursor = 'col-resize'
const container = el.parentElement
if (!container) return
const bounds = container.getBoundingClientRect()
const handleMouseMove = (me: MouseEvent) => {
// Получаем актуальные границы контейнера
const currentBounds = container.getBoundingClientRect()
const newLeftWidth = me.clientX - currentBounds.left
const totalWidth = currentBounds.width
// Рассчитываем процент напрямую от позиции мыши
let newLeftPercentage = newLeftWidth / totalWidth
newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage))
// Вызываем callback с новыми процентами
onDrag(newLeftPercentage)
}
const handleMouseUp = () => {
document.body.style.cursor = 'auto'
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
el.addEventListener('mousedown', handleMouseDown)
return () => {
el.removeEventListener('mousedown', handleMouseDown)
}
}, [onDrag])
return ref
}

View File

@@ -0,0 +1,146 @@
import { MergedCell } from '@/types/template'
import { CSSProperties } from 'react'
import { VariableSizeGrid } from 'react-window'
export const ROW_COUNT = 90
export const COL_COUNT = 20
export const ROW_HEIGHT = 28
export const SHEET_NAMES = {
report: 'L',
calculations: 'R',
} as const
export type SheetType = keyof typeof SHEET_NAMES
// Добавляем массив типов листов для итерации
export const SHEET_TYPES: SheetType[] = ['report', 'calculations']
// Кэшированные данные ячеек для оптимизации
export interface CachedCellData {
displayValue: string
rawValue: string
isModified: boolean
hasRightContent: boolean
}
export interface DualSpreadsheetProps {
templateData?: Record<string, Record<string, any>>
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
templateId?: string // Добавляем ID шаблона для сохранения
}
export interface CellProps {
sheetType: SheetType
rowIndex: number
colIndex: number
columnWidth: number
columnWidths: number[] // Добавляем массив всех ширин колонок
isSelected: boolean
isEditing: boolean
displayValue: string // Отображаемое значение (вычисленное)
rawValue: string // "Сырое" значение (формула)
isModified: boolean
hasRightContent: boolean // Есть ли содержимое в ячейках справа
onCellClick: (row: number, col: number) => void
onCellDoubleClick: (row: number, col: number) => void
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellRef: React.RefObject<HTMLInputElement>
style: CSSProperties // Добавляем style для react-window
mergedCellInfo?: {
// Информация об объединенных ячейках
isMerged: boolean
isMainCell: boolean
rowSpan?: number
colSpan?: number
}
}
export interface FormulaBarProps {
sheetType: SheetType
activeSheet: SheetType
selectedCell: { row: number; col: number } | null
formulaBarValue: string
isCalculating: boolean
isEditing: boolean
onValueChange: (value: string) => void
onFocus: () => void
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
formulaBarRef: (el: HTMLInputElement | null) => void
// Обновляем props для сохранения
isSaving?: boolean
hasUnsavedChanges?: boolean
lastSaveError?: string | null
onManualSave?: () => void
onClearError?: () => void
}
export interface CellRendererData {
sheetType: SheetType
widths: number[]
selectedCell: { row: number; col: number } | null
activeSheet: SheetType
isEditing: boolean
editingValue: string
cachedData: Record<string, CachedCellData>
mergedCells?: MergedCell[]
handleCellClick: (row: number, col: number) => void
handleCellDoubleClick: (row: number, col: number) => void
handleCellChange: (newValue: string) => void
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellInputRef: React.RefObject<HTMLInputElement>
}
export interface CellRendererProps {
columnIndex: number
rowIndex: number
style: CSSProperties
data: CellRendererData
}
export interface SpreadsheetProps {
sheetType: SheetType
columnWidths: number[]
spreadsheetWidths: { L: number; R: number }
getCachedCellData: (sheetType: SheetType) => Record<string, CachedCellData>
selectedCell: { row: number; col: number } | null
activeSheet: SheetType
isEditing: boolean
editingValue: string
handleCellClick: (row: number, col: number) => void
handleCellDoubleClick: (row: number, col: number) => void
handleCellChange: (newValue: string) => void
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellInputRef: React.RefObject<HTMLInputElement>
containerRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
handleKeyDown: (e: React.KeyboardEvent) => void
headerRefs: React.MutableRefObject<Record<SheetType, HTMLDivElement | null>>
rowHeaderRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
gridRefs: React.MutableRefObject<Record<SheetType, VariableSizeGrid | null>>
onGridScroll: (
sheetType: SheetType,
params: { scrollLeft: number; scrollTop: number }
) => void
handleColumnResize: (
sheetType: SheetType,
colIndex: number,
newWidth: number
) => void
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
}
export interface MergedCellInfo {
isMerged: boolean
isMainCell: boolean
mergedCell?: MergedCell
spans?: { rowSpan: number; colSpan: number }
}

View File

@@ -0,0 +1,51 @@
import { MergedCell } from '@/types/template'
import { MergedCellInfo } from './types'
// --- Helper Functions ---
export const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
// Функция для преобразования адреса ячейки в координаты
export const cellAddressToCoords = (
address: string
): { row: number; col: number } => {
const match = address.match(/^([A-Z]+)(\d+)$/)
if (!match) return { row: 0, col: 0 }
const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы
const row = parseInt(match[2]) - 1
return { row, col }
}
// Функция для проверки, является ли ячейка частью объединенной группы
export const findMergedCellInfo = (
row: number,
col: number,
mergedCells: MergedCell[]
): MergedCellInfo => {
for (const merged of mergedCells) {
const fromCoords = cellAddressToCoords(merged.from)
const toCoords = cellAddressToCoords(merged.to)
// Проверяем, попадает ли текущая ячейка в диапазон объединения
if (
row >= fromCoords.row &&
row <= toCoords.row &&
col >= fromCoords.col &&
col <= toCoords.col
) {
const isMainCell = row === fromCoords.row && col === fromCoords.col
const rowSpan = toCoords.row - fromCoords.row + 1
const colSpan = toCoords.col - fromCoords.col + 1
return {
isMerged: true,
isMainCell,
mergedCell: merged,
spans: { rowSpan, colSpan },
}
}
}
return { isMerged: false, isMainCell: false }
}