1005 lines
33 KiB
TypeScript
1005 lines
33 KiB
TypeScript
import { useFileData, useLatestFileForTemplate } from '@/hook/useFileQueries'
|
||
import { useMultiSheetEngine } from '@/hook/useMultiSheetEngine'
|
||
import { useSheetAutoSave } from '@/hook/useSheetAutoSave'
|
||
import { produce } from 'immer'
|
||
import React, {
|
||
FC,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react'
|
||
|
||
// Импорты из новых модулей
|
||
import { FormulaBar, Spreadsheet } from './components'
|
||
import { useDraggableDivider } from './hooks/useDraggableDivider'
|
||
import {
|
||
CachedCellData,
|
||
CellSelection,
|
||
COL_COUNT,
|
||
DualSpreadsheetProps,
|
||
HighlightedCell,
|
||
ROW_COUNT,
|
||
SHEET_NAMES,
|
||
SHEET_TYPES,
|
||
SheetType,
|
||
} from './types'
|
||
import {
|
||
convertReferencesToHighlights,
|
||
createCellAddress,
|
||
createQualifiedCellReference,
|
||
createSelectionFromRange,
|
||
extractCellReferences,
|
||
getAllCellsInSelections,
|
||
getColumnLabel,
|
||
} from './utils'
|
||
|
||
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||
templateData = {},
|
||
mergedCells = [],
|
||
templateId,
|
||
onEngineReady,
|
||
enableAutoSave = true,
|
||
}) => {
|
||
// Мемоизируем конфигурацию листов
|
||
const sheetsConfig = useMemo(
|
||
() => [
|
||
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
|
||
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
|
||
],
|
||
[]
|
||
)
|
||
|
||
const { revision, updateRevision, setTemplateData, ...multiSheetEngine } =
|
||
useMultiSheetEngine({
|
||
sheets: sheetsConfig,
|
||
initialData: templateData || {},
|
||
})
|
||
|
||
// Мемоизируем объект для передачи родительскому компоненту
|
||
const engineAPI = useMemo(
|
||
() => ({
|
||
...multiSheetEngine,
|
||
updateRevision,
|
||
}),
|
||
[multiSheetEngine, updateRevision]
|
||
)
|
||
|
||
// Передаем методы движка родительскому компоненту
|
||
useEffect(() => {
|
||
if (onEngineReady) {
|
||
onEngineReady(engineAPI)
|
||
}
|
||
}, [onEngineReady, engineAPI])
|
||
|
||
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
|
||
const [selectedCell, setSelectedCell] = useState<{
|
||
row: number
|
||
col: number
|
||
} | null>(null)
|
||
|
||
// Флаг, что пользователь изменил хотя бы одну ячейку
|
||
const [userEdited, setUserEdited] = useState(false)
|
||
|
||
// Состояние для множественного выделения
|
||
const [multiSelection, setMultiSelection] = useState<CellSelection[]>([])
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [dragStart, setDragStart] = useState<{
|
||
row: number
|
||
col: number
|
||
} | null>(null)
|
||
const [isEditing, setIsEditing] = useState(false)
|
||
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
|
||
// Источник ввода: 'cell' (редактирование внутри ячейки) или 'formula'
|
||
const [editingSource, setEditingSource] = useState<'cell' | 'formula'>('cell')
|
||
const [isInitialized, setIsInitialized] = useState(false)
|
||
const [baseFileData, setBaseFileData] = useState<
|
||
Record<string, Record<string, any>>
|
||
>({}) // Базовые данные файла
|
||
const [highlightedCells, setHighlightedCells] = useState<HighlightedCell[]>(
|
||
[]
|
||
)
|
||
|
||
// React Query хуки для оптимизации API запросов
|
||
const { data: latestFile, isLoading: isLoadingLatestFile } =
|
||
useLatestFileForTemplate(templateId)
|
||
const { data: fileData, isLoading: isLoadingFileData } = useFileData(
|
||
latestFile?.id
|
||
)
|
||
|
||
// Мемоизируем начальное состояние ширин колонок
|
||
const initialColumnWidths = useMemo(
|
||
() => ({
|
||
report: Array(COL_COUNT).fill(96),
|
||
calculations: Array(COL_COUNT).fill(96),
|
||
}),
|
||
[]
|
||
)
|
||
|
||
const [columnWidths, setColumnWidths] =
|
||
useState<Record<SheetType, number[]>>(initialColumnWidths)
|
||
|
||
const gridRefs = useRef<Record<SheetType, any>>({
|
||
report: null,
|
||
calculations: null,
|
||
})
|
||
const containerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
||
report: null,
|
||
calculations: null,
|
||
})
|
||
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
||
report: null,
|
||
calculations: null,
|
||
})
|
||
const rowHeaderRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
||
report: null,
|
||
calculations: null,
|
||
})
|
||
|
||
const activeCellInputRef = useRef<HTMLInputElement | null>(null)
|
||
const formulaBarRef = useRef<HTMLInputElement | null>(null)
|
||
const initialTemplateValues = useRef<Record<string, string>>({})
|
||
|
||
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
|
||
{}
|
||
)
|
||
const lastRevision = useRef<number>(-1)
|
||
|
||
// Мемоизируем обработчик обновления начальных значений
|
||
const updateInitialValues = useCallback(() => {
|
||
// Обновляем начальные значения только если есть данные
|
||
const hasBaseFileData = Object.keys(baseFileData).length > 0
|
||
const hasTemplateData = Object.keys(templateData).length > 0
|
||
|
||
if (hasBaseFileData) {
|
||
Object.entries(baseFileData).forEach(([sheetName, sheetData]) => {
|
||
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||
initialTemplateValues.current[cellAddress] = String(value || '')
|
||
})
|
||
})
|
||
} else if (hasTemplateData) {
|
||
Object.entries(templateData).forEach(([sheetName, sheetData]) => {
|
||
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||
initialTemplateValues.current[cellAddress] = String(value || '')
|
||
})
|
||
})
|
||
}
|
||
}, [baseFileData, templateData])
|
||
|
||
useEffect(() => {
|
||
updateInitialValues()
|
||
}, [updateInitialValues])
|
||
|
||
// Мемоизируем обработчик скролла к выбранной ячейке
|
||
const scrollToSelectedCell = useCallback(() => {
|
||
if (selectedCell && activeSheet) {
|
||
const grid = gridRefs.current[activeSheet]
|
||
grid?.scrollToItem({
|
||
rowIndex: selectedCell.row,
|
||
columnIndex: selectedCell.col,
|
||
align: 'auto',
|
||
})
|
||
}
|
||
}, [selectedCell, activeSheet])
|
||
|
||
useEffect(() => {
|
||
scrollToSelectedCell()
|
||
}, [scrollToSelectedCell])
|
||
|
||
const getCachedCellData = useCallback(
|
||
(sheetType: SheetType): Record<string, CachedCellData> => {
|
||
const cacheKey = `${sheetType}-${revision}-${columnWidths[sheetType].join('-')}`
|
||
|
||
if (cellDataCache.current[cacheKey]) {
|
||
return cellDataCache.current[cacheKey]
|
||
}
|
||
|
||
const sheetName = SHEET_NAMES[sheetType]
|
||
const cachedData: Record<string, CachedCellData> = {}
|
||
|
||
const allCellValues: Record<string, string> = {}
|
||
for (let row = 0; row < ROW_COUNT; row++) {
|
||
for (let col = 0; col < COL_COUNT; col++) {
|
||
const cellKey = `${row}-${col}`
|
||
const displayValue = String(
|
||
multiSheetEngine.getCellValue(sheetName, row, col) || ''
|
||
)
|
||
allCellValues[cellKey] = displayValue.trim()
|
||
}
|
||
}
|
||
|
||
// 1. Находим ближайшую занятую колонку справа для каждой ячейки строки
|
||
const nextContentInRow: Record<number, number[]> = {}
|
||
for (let row = 0; row < ROW_COUNT; row++) {
|
||
nextContentInRow[row] = Array(COL_COUNT).fill(COL_COUNT)
|
||
let next = COL_COUNT
|
||
for (let col = COL_COUNT - 1; col >= 0; col--) {
|
||
const cellKey = `${row}-${col}`
|
||
nextContentInRow[row][col] = next // сохранить для использования ниже
|
||
if (allCellValues[cellKey]) {
|
||
next = col // текущая колонка занята → "новый" предел
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Заполняем cache
|
||
for (let row = 0; row < ROW_COUNT; row++) {
|
||
for (let col = 0; col < COL_COUNT; col++) {
|
||
const cellKey = `${row}-${col}`
|
||
const rawValue = multiSheetEngine.getCellRawValue(sheetName, row, col)
|
||
const displayValue = allCellValues[cellKey]
|
||
const nextCol = nextContentInRow[row][col]
|
||
|
||
// ширина от текущей колонки до nextCol-1 включительно
|
||
const availablePx = columnWidths[sheetType]
|
||
.slice(col, nextCol)
|
||
.reduce((sum, w) => sum + w, 0)
|
||
|
||
const cellName = getColumnLabel(col) + (row + 1)
|
||
const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла
|
||
|
||
// Нормализуем значения для сравнения
|
||
const normalizeValue = (value: any): string => {
|
||
if (value === null || value === undefined) return ''
|
||
const str = String(value).trim()
|
||
// Считаем "0" и пустую строку эквивалентными
|
||
if (str === '0' || str === '') return ''
|
||
return str
|
||
}
|
||
|
||
const normalizedBaseFile = normalizeValue(baseFileValue)
|
||
const normalizedRaw = normalizeValue(rawValue)
|
||
|
||
const isModified =
|
||
sheetType === 'report' && normalizedRaw !== normalizedBaseFile
|
||
|
||
cachedData[cellKey] = {
|
||
displayValue,
|
||
rawValue,
|
||
isModified,
|
||
nextContentCol: nextCol,
|
||
availablePx,
|
||
}
|
||
}
|
||
}
|
||
|
||
if (lastRevision.current !== revision) {
|
||
Object.keys(cellDataCache.current).forEach(key => {
|
||
if (!key.endsWith(`-${revision}`)) {
|
||
delete cellDataCache.current[key]
|
||
}
|
||
})
|
||
lastRevision.current = revision
|
||
}
|
||
|
||
cellDataCache.current[cacheKey] = cachedData
|
||
return cachedData
|
||
},
|
||
[revision, multiSheetEngine, initialTemplateValues, columnWidths]
|
||
)
|
||
|
||
// Функция для обновления подсветки ячеек при вводе формулы
|
||
const updateCellHighlights = useCallback(
|
||
(formula: string) => {
|
||
if (!formula.startsWith('=')) {
|
||
setHighlightedCells([])
|
||
return
|
||
}
|
||
|
||
try {
|
||
// Извлекаем ссылки на ячейки из формулы
|
||
const references = extractCellReferences(formula)
|
||
|
||
// Конвертируем ссылки в подсвеченные ячейки
|
||
const highlights = convertReferencesToHighlights(
|
||
references,
|
||
activeSheet
|
||
)
|
||
|
||
setHighlightedCells(highlights)
|
||
} catch (error) {
|
||
console.error('Ошибка при обновлении подсветки ячеек:', error)
|
||
setHighlightedCells([])
|
||
}
|
||
},
|
||
[activeSheet]
|
||
)
|
||
|
||
const formulaBarValue = useMemo(() => {
|
||
if (isEditing) {
|
||
return editingValue
|
||
}
|
||
if (selectedCell) {
|
||
const sheetName = SHEET_NAMES[activeSheet]
|
||
return multiSheetEngine.getCellRawValue(
|
||
sheetName,
|
||
selectedCell.row,
|
||
selectedCell.col
|
||
)
|
||
}
|
||
return ''
|
||
}, [
|
||
isEditing,
|
||
editingValue,
|
||
selectedCell,
|
||
activeSheet,
|
||
multiSheetEngine,
|
||
revision,
|
||
])
|
||
|
||
useEffect(() => {
|
||
if (isEditing && activeCellInputRef.current) {
|
||
const input = activeCellInputRef.current
|
||
input.focus()
|
||
const length = input.value.length
|
||
input.setSelectionRange(length, length)
|
||
}
|
||
}, [isEditing])
|
||
|
||
// Синхронизация между formula bar и ячейкой при редактировании
|
||
useEffect(() => {
|
||
if (isEditing && formulaBarRef.current) {
|
||
formulaBarRef.current.value = editingValue
|
||
}
|
||
}, [editingValue, isEditing])
|
||
|
||
// Обновление подсветки ячеек при вводе формулы
|
||
useEffect(() => {
|
||
if (isEditing) {
|
||
updateCellHighlights(editingValue)
|
||
} else {
|
||
// Очищаем подсветку когда не редактируем
|
||
setHighlightedCells([])
|
||
}
|
||
}, [isEditing, editingValue, updateCellHighlights])
|
||
|
||
const startEditing = useCallback(() => {
|
||
if (!selectedCell) return
|
||
// Сбрасываем множественное выделение при начале редактирования
|
||
setMultiSelection([])
|
||
const sheetName = SHEET_NAMES[activeSheet]
|
||
const rawValue = multiSheetEngine.getCellRawValue(
|
||
sheetName,
|
||
selectedCell.row,
|
||
selectedCell.col
|
||
)
|
||
setIsEditing(true)
|
||
setEditingValue(rawValue)
|
||
}, [activeSheet, selectedCell, multiSheetEngine])
|
||
|
||
const stopEditing = useCallback(
|
||
(save: boolean) => {
|
||
if (save && selectedCell) {
|
||
const sheetName = SHEET_NAMES[activeSheet]
|
||
|
||
multiSheetEngine.setCellValueWithoutRecalc(
|
||
sheetName,
|
||
selectedCell.row,
|
||
selectedCell.col,
|
||
editingValue
|
||
)
|
||
updateRevision()
|
||
multiSheetEngine.debouncedRecalc()
|
||
}
|
||
setIsEditing(false)
|
||
setEditingValue('')
|
||
setEditingSource('cell')
|
||
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
||
},
|
||
[activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision]
|
||
)
|
||
|
||
const handleCellChange = useCallback(
|
||
(newValue: string) => {
|
||
if (isEditing) {
|
||
setEditingValue(newValue)
|
||
if (!userEdited) setUserEdited(true)
|
||
}
|
||
},
|
||
[isEditing, userEdited]
|
||
)
|
||
|
||
const handleClearCell = useCallback(() => {
|
||
if (!selectedCell) return
|
||
const sheetName = SHEET_NAMES[activeSheet]
|
||
|
||
multiSheetEngine.setCellValueWithoutRecalc(
|
||
sheetName,
|
||
selectedCell.row,
|
||
selectedCell.col,
|
||
''
|
||
)
|
||
setUserEdited(true)
|
||
updateRevision()
|
||
multiSheetEngine.debouncedRecalc()
|
||
setMultiSelection([])
|
||
}, [activeSheet, selectedCell, multiSheetEngine, updateRevision])
|
||
|
||
const moveSelection = useCallback(
|
||
(deltaRow: number, deltaCol: number) => {
|
||
if (!selectedCell) {
|
||
setSelectedCell({ row: 0, col: 0 })
|
||
return
|
||
}
|
||
|
||
if (isEditing) {
|
||
stopEditing(true)
|
||
}
|
||
|
||
const newRow = Math.max(
|
||
0,
|
||
Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow)
|
||
)
|
||
const newCol = Math.max(
|
||
0,
|
||
Math.min(COL_COUNT - 1, selectedCell.col + deltaCol)
|
||
)
|
||
|
||
setSelectedCell({ row: newRow, col: newCol })
|
||
// Сбрасываем множественное выделение при навигации
|
||
setMultiSelection([])
|
||
},
|
||
[selectedCell, isEditing, stopEditing]
|
||
)
|
||
|
||
// Функция для очистки множества выделенных ячеек
|
||
const handleClearMultipleCells = useCallback(() => {
|
||
if (multiSelection.length === 0) return
|
||
|
||
const sheetName = SHEET_NAMES[activeSheet]
|
||
const cellsToDelete = getAllCellsInSelections(multiSelection)
|
||
|
||
cellsToDelete.forEach(({ row, col }) => {
|
||
multiSheetEngine.setCellValueWithoutRecalc(sheetName, row, col, '')
|
||
})
|
||
|
||
setUserEdited(true)
|
||
updateRevision()
|
||
multiSheetEngine.debouncedRecalc()
|
||
setMultiSelection([])
|
||
}, [multiSelection, activeSheet, multiSheetEngine, updateRevision])
|
||
|
||
const handleKeyDown = useCallback(
|
||
(e: React.KeyboardEvent) => {
|
||
if (isEditing) return
|
||
|
||
if (!selectedCell && e.key.match(/^(Arrow|Enter|F2|Delete|Backspace)$/)) {
|
||
moveSelection(0, 0)
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
if (!selectedCell) return
|
||
|
||
const keyHandlers: Record<string, () => void> = {
|
||
ArrowUp: () => moveSelection(-1, 0),
|
||
ArrowDown: () => moveSelection(1, 0),
|
||
ArrowLeft: () => moveSelection(0, -1),
|
||
ArrowRight: () => moveSelection(0, 1),
|
||
Enter: () => {
|
||
setEditingSource('cell')
|
||
startEditing()
|
||
},
|
||
F2: () => {
|
||
setEditingSource('cell')
|
||
startEditing()
|
||
},
|
||
Delete: () => {
|
||
// Если есть множественное выделение, удаляем все выделенные ячейки
|
||
if (multiSelection.length > 0) {
|
||
handleClearMultipleCells()
|
||
} else {
|
||
handleClearCell()
|
||
}
|
||
},
|
||
Backspace: () => {
|
||
// Если есть множественное выделение, удаляем все выделенные ячейки
|
||
if (multiSelection.length > 0) {
|
||
handleClearMultipleCells()
|
||
} else {
|
||
handleClearCell()
|
||
}
|
||
},
|
||
Escape: () => {
|
||
// Сбрасываем множественное выделение
|
||
setMultiSelection([])
|
||
},
|
||
}
|
||
|
||
// Ctrl+A - выделить все ячейки
|
||
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault()
|
||
const allSelection = createSelectionFromRange(
|
||
0,
|
||
0,
|
||
ROW_COUNT - 1,
|
||
COL_COUNT - 1
|
||
)
|
||
setMultiSelection([allSelection])
|
||
return
|
||
}
|
||
|
||
if (keyHandlers[e.key]) {
|
||
e.preventDefault()
|
||
keyHandlers[e.key]()
|
||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||
e.preventDefault()
|
||
setEditingSource('cell')
|
||
setEditingValue(e.key)
|
||
setIsEditing(true)
|
||
if (!userEdited) setUserEdited(true)
|
||
}
|
||
},
|
||
[
|
||
selectedCell,
|
||
isEditing,
|
||
moveSelection,
|
||
startEditing,
|
||
handleClearCell,
|
||
multiSelection,
|
||
handleClearMultipleCells,
|
||
]
|
||
)
|
||
|
||
const handleCellClick = useCallback(
|
||
(row: number, col: number, event?: React.MouseEvent) => {
|
||
if (isEditing) {
|
||
stopEditing(true)
|
||
}
|
||
|
||
// Если удерживается Ctrl (или Cmd на Mac), добавляем к множественному выделению
|
||
if (event && (event.ctrlKey || event.metaKey)) {
|
||
const newSelection = createSelectionFromRange(row, col, row, col)
|
||
setMultiSelection(prev => [...prev, newSelection])
|
||
setSelectedCell({ row, col })
|
||
return
|
||
}
|
||
|
||
// Если удерживается Shift, расширяем выделение
|
||
if (event && event.shiftKey && selectedCell) {
|
||
const newSelection = createSelectionFromRange(
|
||
selectedCell.row,
|
||
selectedCell.col,
|
||
row,
|
||
col
|
||
)
|
||
setMultiSelection([newSelection])
|
||
return
|
||
}
|
||
|
||
// Обычный клик - очищаем множественное выделение
|
||
setMultiSelection([])
|
||
setSelectedCell({ row, col })
|
||
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
||
},
|
||
[isEditing, stopEditing, activeSheet, selectedCell]
|
||
)
|
||
|
||
// Новый обработчик для начала drag selection
|
||
const handleCellMouseDown = useCallback(
|
||
(row: number, col: number, event: React.MouseEvent) => {
|
||
// Игнорируем правый клик
|
||
if (event.button !== 0) return
|
||
|
||
// Если удерживаются модификаторы, не начинаем drag
|
||
if (event.ctrlKey || event.metaKey || event.shiftKey) return
|
||
|
||
setIsDragging(true)
|
||
setDragStart({ row, col })
|
||
setMultiSelection([])
|
||
setSelectedCell({ row, col })
|
||
},
|
||
[]
|
||
)
|
||
|
||
// Обработчик для drag selection
|
||
const handleCellMouseEnter = useCallback(
|
||
(row: number, col: number) => {
|
||
if (!isDragging || !dragStart) return
|
||
|
||
const newSelection = createSelectionFromRange(
|
||
dragStart.row,
|
||
dragStart.col,
|
||
row,
|
||
col
|
||
)
|
||
setMultiSelection([newSelection])
|
||
},
|
||
[isDragging, dragStart]
|
||
)
|
||
|
||
// Обработчик завершения drag selection
|
||
const handleCellMouseUp = useCallback(() => {
|
||
if (isDragging) {
|
||
setIsDragging(false)
|
||
setDragStart(null)
|
||
}
|
||
}, [isDragging])
|
||
|
||
// Глобальный обработчик для завершения drag selection
|
||
useEffect(() => {
|
||
const handleGlobalMouseUp = () => {
|
||
if (isDragging) {
|
||
setIsDragging(false)
|
||
setDragStart(null)
|
||
}
|
||
}
|
||
|
||
if (isDragging) {
|
||
document.addEventListener('mouseup', handleGlobalMouseUp)
|
||
return () => {
|
||
document.removeEventListener('mouseup', handleGlobalMouseUp)
|
||
}
|
||
}
|
||
}, [isDragging])
|
||
|
||
// Новый обработчик для кликов в режиме редактирования - вставляет ссылку
|
||
const handleCellReferenceClick = useCallback(
|
||
(targetSheetType: SheetType, row: number, col: number) => {
|
||
if (!isEditing || !selectedCell || !editingValue.startsWith('=')) {
|
||
stopEditing(true)
|
||
setSelectedCell({ row, col })
|
||
return
|
||
}
|
||
|
||
let cellReference: string
|
||
|
||
if (targetSheetType !== activeSheet) {
|
||
cellReference = createQualifiedCellReference(targetSheetType, row, col)
|
||
} else {
|
||
cellReference = createCellAddress(row, col)
|
||
}
|
||
|
||
const newValue = editingValue + cellReference
|
||
setEditingValue(newValue)
|
||
if (!userEdited) setUserEdited(true)
|
||
|
||
if (editingSource === 'formula') {
|
||
if (formulaBarRef.current) {
|
||
formulaBarRef.current.value = newValue
|
||
formulaBarRef.current.focus()
|
||
const length = newValue.length
|
||
formulaBarRef.current.setSelectionRange(length, length)
|
||
}
|
||
} else {
|
||
if (activeCellInputRef.current) {
|
||
activeCellInputRef.current.value = newValue
|
||
activeCellInputRef.current.focus({ preventScroll: true })
|
||
const length = newValue.length
|
||
activeCellInputRef.current.setSelectionRange(length, length)
|
||
}
|
||
}
|
||
},
|
||
[isEditing, selectedCell, editingValue, activeSheet, editingSource]
|
||
)
|
||
|
||
const handleCellDoubleClick = useCallback(
|
||
(row: number, col: number) => {
|
||
setSelectedCell({ row, col })
|
||
setEditingSource('cell')
|
||
startEditing()
|
||
},
|
||
[startEditing]
|
||
)
|
||
|
||
const handleColumnResize = useCallback(
|
||
(sheetType: SheetType, colIndex: number, newWidth: number) => {
|
||
setColumnWidths(
|
||
produce(draft => {
|
||
draft[sheetType][colIndex] = Math.max(60, newWidth)
|
||
const grid = gridRefs.current[sheetType]
|
||
if (grid) {
|
||
grid.resetAfterColumnIndex(colIndex)
|
||
}
|
||
})
|
||
)
|
||
},
|
||
[]
|
||
)
|
||
|
||
const handleFormulaBarChange = useCallback(
|
||
(newValue: string) => {
|
||
if (selectedCell) {
|
||
if (!isEditing) setIsEditing(true)
|
||
setEditingValue(newValue)
|
||
if (!userEdited) setUserEdited(true)
|
||
}
|
||
},
|
||
[selectedCell, isEditing, userEdited]
|
||
)
|
||
|
||
const handleFormulaBarFocus = useCallback(() => {
|
||
if (selectedCell && !isEditing) {
|
||
setEditingSource('formula')
|
||
startEditing()
|
||
}
|
||
}, [selectedCell, isEditing, startEditing])
|
||
|
||
const handleFormulaBarBlur = useCallback(
|
||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||
// Проверяем, не переходит ли фокус в ячейку редактирования
|
||
const relatedTarget = e.relatedTarget as HTMLElement
|
||
if (relatedTarget && relatedTarget.closest('input[type="text"]')) {
|
||
return // Не завершаем редактирование
|
||
}
|
||
// Завершаем редактирование только если фокус ушел полностью
|
||
if (isEditing) {
|
||
stopEditing(true)
|
||
}
|
||
},
|
||
[isEditing, stopEditing]
|
||
)
|
||
|
||
const handleFormulaBarKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
stopEditing(true)
|
||
moveSelection(1, 0)
|
||
setTimeout(() => {
|
||
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
||
}, 0)
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
stopEditing(false)
|
||
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
||
}
|
||
},
|
||
[stopEditing, moveSelection, activeSheet]
|
||
)
|
||
|
||
const onGridScroll = useCallback(
|
||
(
|
||
sheetType: SheetType,
|
||
{ scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number }
|
||
) => {
|
||
if (headerRefs.current[sheetType]) {
|
||
headerRefs.current[sheetType]!.scrollLeft = scrollLeft
|
||
}
|
||
if (rowHeaderRefs.current[sheetType]) {
|
||
rowHeaderRefs.current[sheetType]!.scrollTop = scrollTop
|
||
}
|
||
},
|
||
[]
|
||
)
|
||
|
||
// Мемоизируем состояние ширин таблиц
|
||
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
|
||
L: 0.5,
|
||
R: 0.5,
|
||
})
|
||
|
||
// Мемоизируем опции автосохранения
|
||
const autoSaveOptions = useMemo(
|
||
() => ({
|
||
templateId,
|
||
autoSaveDelay: 2000,
|
||
enableAutoSave: enableAutoSave && userEdited,
|
||
}),
|
||
[templateId, enableAutoSave, userEdited]
|
||
)
|
||
|
||
// Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения
|
||
const memoizedGetAllModifiedCells = useCallback(
|
||
multiSheetEngine.getAllModifiedCells,
|
||
[revision] // Используем revision вместо функции для более стабильной мемоизации
|
||
)
|
||
|
||
// Используем новый хук автосохранения
|
||
const {
|
||
isSaving,
|
||
isLoading,
|
||
hasUnsavedChanges,
|
||
lastSaveError,
|
||
manualSave,
|
||
clearError,
|
||
loadSheets,
|
||
} = useSheetAutoSave(
|
||
memoizedGetAllModifiedCells,
|
||
multiSheetEngine.isCalculating,
|
||
revision,
|
||
autoSaveOptions
|
||
)
|
||
|
||
// Используем хук для перетаскивания разделителя
|
||
const handleDividerDrag = useCallback((newLeftPercentage: number) => {
|
||
// Используем requestAnimationFrame для более плавного обновления
|
||
requestAnimationFrame(() => {
|
||
setSpreadsheetWidths({
|
||
L: newLeftPercentage,
|
||
R: 1 - newLeftPercentage,
|
||
})
|
||
})
|
||
}, [])
|
||
|
||
const separatorRef = useDraggableDivider(handleDividerDrag)
|
||
|
||
// Мемоизируем функцию инициализации данных
|
||
const initializeData = useCallback(async () => {
|
||
if (!templateId || isInitialized) return
|
||
|
||
let finalBaseData: Record<string, Record<string, any>> = {}
|
||
|
||
// 1. ИСПОЛЬЗУЕМ ДАННЫЕ ИЗ REACT QUERY КЕША
|
||
if (fileData && Object.keys(fileData.data).length > 0) {
|
||
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
|
||
setBaseFileData(finalBaseData)
|
||
// Сохраняем baseline, чтобы в листы попадали только пользовательские изменения
|
||
setTemplateData(finalBaseData)
|
||
}
|
||
|
||
// 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные
|
||
const hasTemplateData =
|
||
Object.keys(templateData).length > 0 &&
|
||
Object.values(templateData).some(sheet => Object.keys(sheet).length > 0)
|
||
|
||
if (hasTemplateData && Object.keys(finalBaseData).length === 0) {
|
||
finalBaseData = templateData
|
||
setBaseFileData(finalBaseData)
|
||
setTemplateData(templateData)
|
||
}
|
||
|
||
// 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API
|
||
try {
|
||
const apiSheets = await loadSheets()
|
||
|
||
// Преобразуем листы API в формат данных
|
||
const userChanges: Record<string, Record<string, any>> = {}
|
||
Object.entries(apiSheets).forEach(([sheetName, sheet]) => {
|
||
// Нормализуем имя листа: "Report" -> "L"
|
||
const normalizedName =
|
||
sheetName === 'Report'
|
||
? SHEET_NAMES.report
|
||
: sheetName === 'Calculations'
|
||
? SHEET_NAMES.calculations
|
||
: sheetName
|
||
|
||
// Интересуют только листы L и R
|
||
if (
|
||
(normalizedName === SHEET_NAMES.report ||
|
||
normalizedName === SHEET_NAMES.calculations) &&
|
||
sheet.cells &&
|
||
Object.keys(sheet.cells).length > 0
|
||
) {
|
||
userChanges[normalizedName] = sheet.cells
|
||
}
|
||
})
|
||
|
||
// 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ
|
||
if (Object.keys(userChanges).length > 0) {
|
||
// Создаем объединенные данные: базовые данные файла + пользовательские изменения
|
||
const mergedData: Record<string, Record<string, any>> = {}
|
||
|
||
// Копируем базовые данные файла как основу
|
||
Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => {
|
||
mergedData[sheetName] = { ...sheetData }
|
||
})
|
||
|
||
// Применяем пользовательские изменения поверх базовых данных
|
||
Object.entries(userChanges).forEach(([sheetName, sheetData]) => {
|
||
if (!mergedData[sheetName]) {
|
||
mergedData[sheetName] = {}
|
||
}
|
||
Object.entries(sheetData).forEach(([cellAddress, value]) => {
|
||
mergedData[sheetName][cellAddress] = value
|
||
})
|
||
})
|
||
|
||
multiSheetEngine.loadData(mergedData)
|
||
} else if (Object.keys(finalBaseData).length > 0) {
|
||
multiSheetEngine.loadData(finalBaseData)
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Ошибка при загрузке пользовательских изменений:', error)
|
||
|
||
// В случае ошибки используем только базовые данные
|
||
if (Object.keys(finalBaseData).length > 0) {
|
||
multiSheetEngine.loadData(finalBaseData)
|
||
}
|
||
}
|
||
|
||
setIsInitialized(true)
|
||
}, [
|
||
templateId,
|
||
templateData,
|
||
fileData,
|
||
loadSheets,
|
||
multiSheetEngine,
|
||
setTemplateData,
|
||
isInitialized,
|
||
])
|
||
|
||
// Загружаем сохраненные формулы при инициализации
|
||
// Ждем загрузки данных из React Query перед инициализацией
|
||
useEffect(() => {
|
||
// Инициализируем только когда все данные загружены или их нет вообще
|
||
const canInitialize =
|
||
!isLoadingLatestFile && !isLoadingFileData && templateId && !isInitialized
|
||
|
||
if (canInitialize) {
|
||
initializeData()
|
||
}
|
||
}, [
|
||
initializeData,
|
||
isLoadingLatestFile,
|
||
isLoadingFileData,
|
||
templateId,
|
||
isInitialized,
|
||
])
|
||
|
||
return (
|
||
<div className="flex h-full flex-col bg-background">
|
||
<FormulaBar
|
||
selectedCell={selectedCell}
|
||
formulaBarValue={formulaBarValue}
|
||
isCalculating={multiSheetEngine.isCalculating}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFormulaBarChange}
|
||
onFocus={handleFormulaBarFocus}
|
||
onBlur={handleFormulaBarBlur}
|
||
onKeyDown={handleFormulaBarKeyDown}
|
||
formulaBarRef={el => {
|
||
formulaBarRef.current = el
|
||
}}
|
||
isSaving={isSaving}
|
||
isLoading={isLoading}
|
||
hasUnsavedChanges={hasUnsavedChanges}
|
||
lastSaveError={lastSaveError}
|
||
onManualSave={manualSave}
|
||
onClearError={clearError}
|
||
/>
|
||
<div
|
||
className="flex min-w-0 flex-1 gap-1 overflow-hidden"
|
||
style={{ transition: 'none' }}
|
||
>
|
||
{SHEET_TYPES.map((sheetType, index) => (
|
||
<React.Fragment key={sheetType}>
|
||
<Spreadsheet
|
||
sheetType={sheetType}
|
||
columnWidths={columnWidths[sheetType]}
|
||
spreadsheetWidths={spreadsheetWidths}
|
||
getCachedCellData={getCachedCellData}
|
||
selectedCell={selectedCell}
|
||
multiSelection={multiSelection}
|
||
activeSheet={activeSheet}
|
||
isEditing={isEditing}
|
||
editingValue={editingValue}
|
||
highlightedCells={highlightedCells}
|
||
handleCellClick={handleCellClick}
|
||
handleCellDoubleClick={handleCellDoubleClick}
|
||
handleCellChange={handleCellChange}
|
||
handleCellReferenceClick={handleCellReferenceClick}
|
||
handleCellMouseDown={handleCellMouseDown}
|
||
handleCellMouseEnter={handleCellMouseEnter}
|
||
handleCellMouseUp={handleCellMouseUp}
|
||
isInEditMode={isEditing}
|
||
stopEditing={stopEditing}
|
||
setActiveSheet={setActiveSheet}
|
||
activeCellInputRef={activeCellInputRef}
|
||
containerRefs={containerRefs}
|
||
handleKeyDown={handleKeyDown}
|
||
headerRefs={headerRefs}
|
||
rowHeaderRefs={rowHeaderRefs}
|
||
gridRefs={gridRefs}
|
||
onGridScroll={onGridScroll}
|
||
handleColumnResize={handleColumnResize}
|
||
mergedCells={mergedCells}
|
||
/>
|
||
{index === 0 && (
|
||
<div
|
||
ref={separatorRef}
|
||
className="flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20"
|
||
style={{ userSelect: 'none' }}
|
||
>
|
||
<div className="h-4 w-0.5 rounded-full bg-muted-foreground/50" />
|
||
</div>
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default DualSpreadsheet
|