diff --git a/src/component/DualSpreadsheet/DualSpreadsheet.tsx b/src/component/DualSpreadsheet/DualSpreadsheet.tsx index 6f3b95a..036d871 100644 --- a/src/component/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/component/DualSpreadsheet/DualSpreadsheet.tsx @@ -60,22 +60,6 @@ const DualSpreadsheet: FC = ({ initialData: templateData || {}, }) - // Мемоизируем объект для передачи родительскому компоненту - const engineAPI = useMemo( - () => ({ - ...multiSheetEngine, - updateRevision, - }), - [multiSheetEngine, updateRevision] - ) - - // Передаем методы движка родительскому компоненту - useEffect(() => { - if (onEngineReady) { - onEngineReady(engineAPI) - } - }, [onEngineReady, engineAPI]) - const [activeSheet, setActiveSheet] = useState('report') const [selectedCell, setSelectedCell] = useState<{ row: number @@ -1034,6 +1018,39 @@ const DualSpreadsheet: FC = ({ autoSaveOptions ) + // Используем ref для стабильного API + const engineAPIRef = useRef(null) + + // Мемоизируем объект для передачи родительскому компоненту + const engineAPI = useMemo( + () => ({ + ...multiSheetEngine, + updateRevision, + hasUnsavedChanges, + isSaving, + manualSave, + }), + [multiSheetEngine, updateRevision, hasUnsavedChanges, isSaving, manualSave] + ) + + // Обновляем ref только при значимых изменениях + useEffect(() => { + if (engineAPIRef.current) { + engineAPIRef.current.hasUnsavedChanges = hasUnsavedChanges + engineAPIRef.current.isSaving = isSaving + engineAPIRef.current.manualSave = manualSave + } else { + engineAPIRef.current = engineAPI + } + }, [hasUnsavedChanges, isSaving, manualSave, engineAPI]) + + // Передаем методы движка родительскому компоненту - только один раз после инициализации + useEffect(() => { + if (onEngineReady && isInitialized && engineAPIRef.current) { + onEngineReady(engineAPIRef.current) + } + }, [onEngineReady, isInitialized]) // Убираем engineAPI из зависимостей + // Используем хук для перетаскивания разделителя const handleDividerDrag = useCallback((newLeftPercentage: number) => { // Используем requestAnimationFrame для более плавного обновления diff --git a/src/component/DualSpreadsheet/components/Cell.tsx b/src/component/DualSpreadsheet/components/Cell.tsx index 93a92c7..0a283af 100644 --- a/src/component/DualSpreadsheet/components/Cell.tsx +++ b/src/component/DualSpreadsheet/components/Cell.tsx @@ -11,15 +11,10 @@ export const Cell = memo( isSelected, isInMultiSelection, multiSelectionActive, - selectionTop = false, - selectionBottom = false, - selectionLeft = false, - selectionRight = false, isEditing, displayValue, rawValue, isModified, - nextContentCol, availablePx, onCellClick, onCellDoubleClick, @@ -32,157 +27,98 @@ export const Cell = memo( stopEditing, setActiveSheet, activeCellRef, - style, // Добавляем style для react-window + style, mergedCellInfo, - highlightColor, // Цвет подсветки для формул - isCopied, // Является ли ячейка скопированной - isCut, // Является ли ячейка вырезанной + highlightColor, + isCopied, + isCut, }: CellProps) => { // Если ячейка объединена, но не является основной, не рендерим её if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) { return null } - // Определяем классы CSS для подсветки и обычного состояния - let className = 'border border-border' - - // Базовый style для ячейки - let cellStyle: CSSProperties = { - ...style, - overflow: 'visible', - padding: 0, - userSelect: 'none', - } - - // Приоритет стилей: подсветка формул > множественное выделение > изменения > обычное состояние - if (highlightColor) { - className += ` ${highlightColor}` - } else if (isModified && !mergedCellInfo?.isMerged) { - className += ' bg-yellow-200' - } - - // Настраиваем box-shadow, фон и z-index для выделенной области - if (isInMultiSelection) { - cellStyle = { - ...cellStyle, - backgroundColor: 'rgba(59,130,246,0.08)', // голубоватый оттенок - } - } - - // Стили для скопированных/вырезанных ячеек - if (isCopied || isCut) { - cellStyle = { - ...cellStyle, - border: '2px dashed hsl(var(--primary))', - backgroundColor: isCut - ? 'rgba(239, 68, 68, 0.1)' - : 'rgba(34, 197, 94, 0.1)', // красноватый для вырезанных, зеленоватый для скопированных - } - } - - // Отладка стилей для измененных ячеек - if (isModified) { - // console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, //{ - // className, - // isModified, - // isSelected, - // displayValue, - // highlightColor, - //}) - } - - // Для объединенных ячеек нужно рассчитать правильные размеры - 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 getCellStyles = (): { className: string; style: CSSProperties } => { + let className = 'border border-border' + let cellStyle: CSSProperties = { + ...style, + overflow: 'visible', + padding: 0, + userSelect: 'none', } - // Рассчитываем общую высоту для объединенной ячейки - const totalHeight = ROW_HEIGHT * rowSpan - - // Определяем цвет фона для объединенной ячейки - let backgroundColor = 'white' + // Подсветка формул имеет приоритет if (highlightColor) { - // Если есть подсветка формулы, используем её - const bgMatch = highlightColor.match(/bg-(\w+)-(\d+)/) - if (bgMatch) { - backgroundColor = `var(--${bgMatch[1]}-${bgMatch[2]})` - } - } else if (isSelected) { - backgroundColor = 'hsl(var(--accent))' - } else if (isModified) { - backgroundColor = - '#fef08a' /* тот же жёлтый оттенок, что и bg-yellow-200 */ + className += ` ${highlightColor}` + } else if (isModified && !mergedCellInfo?.isMerged) { + className += ' bg-yellow-200' } - cellStyle = { - ...cellStyle, - width: totalWidth, - height: totalHeight, - zIndex: 10, // Поднимаем выше обычных ячеек - backgroundColor, + // Множественное выделение + if (isInMultiSelection) { + cellStyle.backgroundColor = 'rgba(59,130,246,0.08)' } + + // Стили для скопированных/вырезанных ячеек + if (isCopied || isCut) { + cellStyle.border = '2px dashed hsl(var(--primary))' + cellStyle.backgroundColor = isCut + ? 'rgba(239, 68, 68, 0.1)' + : 'rgba(34, 197, 94, 0.1)' + } + + // Объединенные ячейки + if ( + mergedCellInfo?.isMainCell && + mergedCellInfo.rowSpan && + mergedCellInfo.colSpan + ) { + const { rowSpan, colSpan } = mergedCellInfo + + // Рассчитываем размеры объединенной ячейки + const totalWidth = columnWidths + .slice(colIndex, colIndex + colSpan) + .reduce((sum, width) => sum + width, 0) + const totalHeight = ROW_HEIGHT * rowSpan + + // Определяем фон + let backgroundColor = 'white' + if (highlightColor) { + const bgMatch = highlightColor.match(/bg-(\w+)-(\d+)/) + if (bgMatch) { + backgroundColor = `var(--${bgMatch[1]}-${bgMatch[2]})` + } + } else if (isSelected) { + backgroundColor = 'hsl(var(--accent))' + } else if (isModified) { + backgroundColor = '#fef08a' + } + + cellStyle = { + ...cellStyle, + width: totalWidth, + height: totalHeight, + zIndex: 10, + backgroundColor, + } + } + + return { className, style: cellStyle } } - // Используем обычное значение displayValue для всех ячеек, включая объединенные - const cellDisplayValue = displayValue + const { className, style: cellStyle } = getCellStyles() const handleClick = useCallback( (e: React.MouseEvent) => { - // console.log('🖱️ Cell клик:', { - // rowIndex, - // colIndex, - // sheetType, - // isInEditMode, - // isSelected, - //hasReferenceHandler: !!onCellReferenceClick, - //}) - - // Если мы в режиме редактирования, вставка была обработана на mousedown. - // Останавливаем всплытие, чтобы родительский Spreadsheet не сменил активный лист. if (isInEditMode) { e.preventDefault() e.stopPropagation() return } - - // Обычная логика клика (выделение ячейки) - // console.log('📍 Обычный клик - выбираем ячейку:', { - // rowIndex, - // colIndex, - // sheetType, - //}) - - // Подсказка для пользователя - if (!isInEditMode) { - // console.log( - // '💡 Чтобы вставить ссылку на ячейку: 1) Выберите ячейку 2) Нажмите F2 или Enter для редактирования 3) Кликните на нужную ячейку' - //) - } - onCellClick(rowIndex, colIndex, e) }, - [ - setActiveSheet, - sheetType, - onCellClick, - onCellReferenceClick, - rowIndex, - colIndex, - isInEditMode, - isSelected, - ] + [isInEditMode, onCellClick, rowIndex, colIndex] ) const handleDoubleClick = useCallback( @@ -193,24 +129,18 @@ export const Cell = memo( const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (!isInEditMode) { - // блокируем стандартное выделение текста браузером e.preventDefault() } - // Используем mousedown, чтобы сработать раньше blur FormulaBar + // Вставка ссылки в режиме редактирования if (isInEditMode && onCellReferenceClick && !isSelected) { e.preventDefault() e.stopPropagation() - // console.log('🖱️ mousedown – вставка ссылки до blur:', { - // rowIndex, - // colIndex, - // sheetType, - //}) onCellReferenceClick(sheetType, rowIndex, colIndex) return } - // Обработчик для начала drag selection + // Drag selection if (onCellMouseDown) { setActiveSheet(sheetType) onCellMouseDown(rowIndex, colIndex, e) @@ -229,17 +159,57 @@ export const Cell = memo( ) const handleMouseEnter = useCallback(() => { - if (onCellMouseEnter) { - onCellMouseEnter(rowIndex, colIndex) - } + onCellMouseEnter?.(rowIndex, colIndex) }, [onCellMouseEnter, rowIndex, colIndex]) const handleMouseUp = useCallback(() => { - if (onCellMouseUp) { - onCellMouseUp() - } + onCellMouseUp?.() }, [onCellMouseUp]) + const handleInputBlur = useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement + if (relatedTarget?.closest('.formula-bar')) { + return + } + stopEditing(true) + }, + [stopEditing] + ) + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + stopEditing(true) + } else if (e.key === 'Escape') { + e.preventDefault() + stopEditing(false) + } + }, + [stopEditing] + ) + + // Стили для отображения содержимого + const contentStyles: CSSProperties = { + overflow: availablePx > columnWidth ? 'visible' : 'hidden', + whiteSpace: 'nowrap', + ...(isSelected && !multiSelectionActive + ? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' } + : highlightColor + ? { boxShadow: 'inset 0 0 0 2px currentColor' } + : {}), + } + + const textStyles: CSSProperties = { + width: availablePx > columnWidth ? availablePx - 8 : columnWidth - 16, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + pointerEvents: 'none', + zIndex: 1, + } + return (
} type="text" - value={rawValue} // Во время редактирования показываем текущее редактируемое значение + value={rawValue} onChange={e => onCellValueChange(e.target.value)} style={{ width: columnWidth - 4 }} - 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) - } - }} + onBlur={handleInputBlur} + onKeyDown={handleInputKeyDown} className="absolute left-0 top-0 z-20 box-border h-full border-2 border-primary px-2 py-1" /> ) : ( - // Контейнер, который допускает перепрыгивание текста, если впереди есть свободное место
columnWidth ? 'visible' : 'hidden', - whiteSpace: 'nowrap', - ...(isSelected && !multiSelectionActive - ? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' } - : highlightColor - ? { boxShadow: 'inset 0 0 0 2px currentColor' } - : {}), - }} + style={contentStyles} >
columnWidth - ? availablePx - 8 - : columnWidth - 16, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - pointerEvents: 'none', // чтобы клики шли в "родную" ячейку - zIndex: 1, - }} + style={textStyles} > - {cellDisplayValue} + {displayValue}
)} diff --git a/src/component/DualSpreadsheet/components/CellRenderer.tsx b/src/component/DualSpreadsheet/components/CellRenderer.tsx index 724a3ee..267c011 100644 --- a/src/component/DualSpreadsheet/components/CellRenderer.tsx +++ b/src/component/DualSpreadsheet/components/CellRenderer.tsx @@ -2,7 +2,6 @@ import { CellRendererProps } from '../types' import { findMergedCellInfo, isInAnySelection } from '../utils' import { Cell } from './Cell' -// Рендер-проп для ячеек грида export const CellRenderer = ({ columnIndex, rowIndex, @@ -34,55 +33,25 @@ export const CellRenderer = ({ activeCellInputRef, } = data + const isActiveSheet = activeSheet === sheetType const isCellSelected = selectedCell?.row === rowIndex && selectedCell?.col === columnIndex && - activeSheet === sheetType + isActiveSheet - // Проверяем, является ли ячейка частью множественного выделения const isInMultiSelection = - sheetType === activeSheet && - isInAnySelection(rowIndex, columnIndex, multiSelection) - - const multiSelectionActive = - sheetType === activeSheet && multiSelection.length > 0 - - // Вычисляем границы выделения, если ячейка внутри выделения - let selectionTop = false, - selectionBottom = false, - selectionLeft = false, - selectionRight = false - - if (isInMultiSelection && multiSelection.length > 0) { - const bounds = { - top: Math.min(...multiSelection.map(s => Math.min(s.startRow, s.endRow))), - bottom: Math.max( - ...multiSelection.map(s => Math.max(s.startRow, s.endRow)) - ), - left: Math.min( - ...multiSelection.map(s => Math.min(s.startCol, s.endCol)) - ), - right: Math.max( - ...multiSelection.map(s => Math.max(s.startCol, s.endCol)) - ), - } - selectionTop = rowIndex === bounds.top - selectionBottom = rowIndex === bounds.bottom - selectionLeft = columnIndex === bounds.left - selectionRight = columnIndex === bounds.right - } + isActiveSheet && isInAnySelection(rowIndex, columnIndex, multiSelection) + const multiSelectionActive = isActiveSheet && multiSelection.length > 0 const isCellEditing = isCellSelected && isEditing const cellKey = `${rowIndex}-${columnIndex}` const cellData = cachedData[cellKey] const rawValue = isCellEditing ? editingValue : cellData?.rawValue || '' - const displayValue = isCellEditing - ? '' // Не показывать вычисленное значение во время редактирования - : cellData?.displayValue || '' + const displayValue = isCellEditing ? '' : cellData?.displayValue || '' - // Проверяем, подсвечена ли эта ячейка + // Подсветка ячеек для формул const currentSheetName = sheetType === 'report' ? 'L' : 'R' const highlightedCell = highlightedCells.find( highlight => @@ -90,23 +59,8 @@ export const CellRenderer = ({ highlight.row === rowIndex && highlight.col === columnIndex ) - const highlightColor = highlightedCell?.color - // Отладка для измененных ячеек - if (cellData?.isModified) { - // console.log( - // `🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`, - // { - // cellKey, - // isModified: cellData.isModified, - // displayValue, - // rawValue, - // highlightColor, - // } - // ) - } - - // Получаем информацию об объединенных ячейках только для листа отчета + // Объединенные ячейки только для листа отчета const mergedCellInfo = sheetType === 'report' && mergedCells ? findMergedCellInfo(rowIndex, columnIndex, mergedCells) @@ -118,14 +72,12 @@ export const CellRenderer = ({ isMainCell: mergedCellInfo.isMainCell, rowSpan: mergedCellInfo.spans?.rowSpan, colSpan: mergedCellInfo.spans?.colSpan, - // Убираем mergedValue - будем использовать актуальные данные из движка } : undefined - // Проверяем, является ли ячейка скопированной + // Скопированные/вырезанные ячейки const isCopied = Boolean( - copiedCells && - copiedCells.sourceSheet === sheetType && + copiedCells?.sourceSheet === sheetType && copiedCells.data.some( cell => cell.row === rowIndex && cell.col === columnIndex ) @@ -143,15 +95,10 @@ export const CellRenderer = ({ isSelected={isCellSelected} isInMultiSelection={isInMultiSelection} multiSelectionActive={multiSelectionActive} - selectionTop={selectionTop} - selectionBottom={selectionBottom} - selectionLeft={selectionLeft} - selectionRight={selectionRight} isEditing={isCellEditing} displayValue={displayValue} rawValue={rawValue} isModified={cellData?.isModified || false} - nextContentCol={cellData?.nextContentCol || 0} availablePx={cellData?.availablePx || widths[columnIndex]} onCellClick={handleCellClick} onCellDoubleClick={handleCellDoubleClick} @@ -165,7 +112,7 @@ export const CellRenderer = ({ setActiveSheet={setActiveSheet} activeCellRef={activeCellInputRef} mergedCellInfo={mergedCellProps} - highlightColor={highlightColor} + highlightColor={highlightedCell?.color} isCopied={isCopied} isCut={isCut} /> diff --git a/src/component/DualSpreadsheet/components/FormulaBar.tsx b/src/component/DualSpreadsheet/components/FormulaBar.tsx index c0c2dce..632cd4a 100644 --- a/src/component/DualSpreadsheet/components/FormulaBar.tsx +++ b/src/component/DualSpreadsheet/components/FormulaBar.tsx @@ -185,6 +185,16 @@ export const FormulaBar = memo(
)} + {/* ExcelUploadPanel */} + {onExcelFileChange && ( + {}} + onFileChange={onExcelFileChange} + /> + )} + {/* Кнопка сохранения */} {onManualSave && ( )} - - {/* ExcelUploadPanel */} - {onExcelFileChange && ( - {}} - onFileChange={onExcelFileChange} - /> - )} diff --git a/src/component/DualSpreadsheet/components/Spreadsheet.tsx b/src/component/DualSpreadsheet/components/Spreadsheet.tsx index daabf55..76684e1 100644 --- a/src/component/DualSpreadsheet/components/Spreadsheet.tsx +++ b/src/component/DualSpreadsheet/components/Spreadsheet.tsx @@ -4,10 +4,8 @@ import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types' import { getColumnLabel } from '../utils' import { CellRenderer } from './CellRenderer' -// Создаем типизированный компонент для совместимости с React 18 const Grid = VariableSizeGrid as unknown as React.ComponentType -// --- Spreadsheet Component --- export const Spreadsheet = ({ sheetType, columnWidths, @@ -40,12 +38,12 @@ export const Spreadsheet = ({ mergedCells, copiedCells, }: SpreadsheetProps) => { - const widths = columnWidths const cachedData = getCachedCellData(sheetType) + const isActiveSheet = activeSheet === sheetType const itemData = { sheetType, - widths, + widths: columnWidths, selectedCell, multiSelection, activeSheet, @@ -55,8 +53,6 @@ export const Spreadsheet = ({ mergedCells, highlightedCells, copiedCells, - multiSelectionActive: - activeSheet === sheetType && multiSelection.length > 0, handleCellClick, handleCellDoubleClick, handleCellChange, @@ -70,19 +66,8 @@ export const Spreadsheet = ({ activeCellInputRef, } - // Отладочное логирование для проверки состояния - // console.log(`📊 Spreadsheet ${sheetType} состояние:`, { - // isEditing, - // isInEditMode, - // selectedCell, - // activeSheet, - // isActiveSheet: activeSheet === sheetType, - //}) - const parentRef = useRef(null) const [gridSize, setGridSize] = useState({ width: 0, height: 0 }) - - // Состояние текущего скролла грида const [scrollPos, setScrollPos] = useState({ left: 0, top: 0 }) useEffect(() => { @@ -100,17 +85,98 @@ export const Spreadsheet = ({ return () => resizeObserver.disconnect() }, []) - const selectedRows = new Set() - const selectedCols = new Set() - if (sheetType === activeSheet && multiSelection.length > 0) { - multiSelection.forEach(sel => { - const top = Math.min(sel.startRow, sel.endRow) - const bottom = Math.max(sel.startRow, sel.endRow) - const left = Math.min(sel.startCol, sel.endCol) - const right = Math.max(sel.startCol, sel.endCol) - for (let r = top; r <= bottom; r++) selectedRows.add(r) - for (let c = left; c <= right; c++) selectedCols.add(c) - }) + // Вычисляем выделенные строки и колонки для активного листа + const getSelectedRowsAndCols = () => { + const selectedRows = new Set() + const selectedCols = new Set() + + if (isActiveSheet && multiSelection.length > 0) { + multiSelection.forEach(sel => { + const top = Math.min(sel.startRow, sel.endRow) + const bottom = Math.max(sel.startRow, sel.endRow) + const left = Math.min(sel.startCol, sel.endCol) + const right = Math.max(sel.startCol, sel.endCol) + for (let r = top; r <= bottom; r++) selectedRows.add(r) + for (let c = left; c <= right; c++) selectedCols.add(c) + }) + } + + return { selectedRows, selectedCols } + } + + const { selectedRows, selectedCols } = getSelectedRowsAndCols() + + const handleColumnResizeStart = + (colIndex: number) => (e: React.MouseEvent) => { + e.preventDefault() + const startX = e.clientX + const startWidth = columnWidths[colIndex] + + const handleMouseMove = (me: MouseEvent) => { + const newWidth = startWidth + (me.clientX - startX) + handleColumnResize(sheetType, colIndex, newWidth) + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + // Вычисляем границы множественного выделения + const getSelectionBounds = () => { + if (!isActiveSheet || multiSelection.length === 0) return null + + return { + topRow: Math.min( + ...multiSelection.map(sel => Math.min(sel.startRow, sel.endRow)) + ), + bottomRow: Math.max( + ...multiSelection.map(sel => Math.max(sel.startRow, sel.endRow)) + ), + leftCol: Math.min( + ...multiSelection.map(sel => Math.min(sel.startCol, sel.endCol)) + ), + rightCol: Math.max( + ...multiSelection.map(sel => Math.max(sel.startCol, sel.endCol)) + ), + } + } + + const selectionBounds = getSelectionBounds() + + const renderSelectionOverlay = () => { + if (!selectionBounds) return null + + const topPx = selectionBounds.topRow * 28 - scrollPos.top + const heightPx = + (selectionBounds.bottomRow - selectionBounds.topRow + 1) * 28 + const leftPx = + columnWidths + .slice(0, selectionBounds.leftCol) + .reduce((a, b) => a + b, 0) - scrollPos.left + const widthPx = columnWidths + .slice(selectionBounds.leftCol, selectionBounds.rightCol + 1) + .reduce((a, b) => a + b, 0) + + return ( +
+ ) } return ( @@ -131,12 +197,10 @@ export const Spreadsheet = ({ style={{ userSelect: isEditing ? 'text' : 'none' }} tabIndex={0} > - {/* Spreadsheet Body */}
{/* Row Headers */}
- {/* Компактное обозначение листа */}
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => { const isActiveRow = - (selectedCell?.row === rowIndex && - activeSheet === sheetType) || + (selectedCell?.row === rowIndex && isActiveSheet) || selectedRows.has(rowIndex) return (
{rowIndex + 1}
@@ -184,38 +251,25 @@ export const Spreadsheet = ({ className="column-headers overflow-x-hidden" >
- {Array.from({ length: COL_COUNT }).map((_, i) => { + {Array.from({ length: COL_COUNT }).map((_, colIndex) => { const isActiveColumn = - (selectedCell?.col === i && activeSheet === sheetType) || - selectedCols.has(i) - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault() - const startX = e.clientX - const startWidth = widths[i] + (selectedCell?.col === colIndex && isActiveSheet) || + selectedCols.has(colIndex) - 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 (
- {getColumnLabel(i)} + {getColumnLabel(colIndex)}
) @@ -225,60 +279,7 @@ export const Spreadsheet = ({ {/* Grid */}
- {/* selection overlay */} - {sheetType === activeSheet && - multiSelection.length > 0 && - (() => { - const bounds = { - topRow: Math.min( - ...multiSelection.map(sel => - Math.min(sel.startRow, sel.endRow) - ) - ), - bottomRow: Math.max( - ...multiSelection.map(sel => - Math.max(sel.startRow, sel.endRow) - ) - ), - leftCol: Math.min( - ...multiSelection.map(sel => - Math.min(sel.startCol, sel.endCol) - ) - ), - rightCol: Math.max( - ...multiSelection.map(sel => - Math.max(sel.startCol, sel.endCol) - ) - ), - } - - const topPx = - bounds.topRow * 28 /* ROW_HEIGHT */ - scrollPos.top - const heightPx = (bounds.bottomRow - bounds.topRow + 1) * 28 - const leftPx = - widths.slice(0, bounds.leftCol).reduce((a, b) => a + b, 0) - - scrollPos.left - const widthPx = widths - .slice(bounds.leftCol, bounds.rightCol + 1) - .reduce((a, b) => a + b, 0) - - return ( -
- ) - })()} + {renderSelectionOverlay()} { if (gridRefs.current) { @@ -287,7 +288,7 @@ export const Spreadsheet = ({ }} columnCount={COL_COUNT} rowCount={ROW_COUNT} - columnWidth={(index: number) => widths[index]} + columnWidth={(index: number) => columnWidths[index]} rowHeight={() => 28} height={gridSize.height} width={gridSize.width} diff --git a/src/component/DualSpreadsheet/types.ts b/src/component/DualSpreadsheet/types.ts index a9d7a82..dd81865 100644 --- a/src/component/DualSpreadsheet/types.ts +++ b/src/component/DualSpreadsheet/types.ts @@ -64,47 +64,40 @@ export interface CellProps { rowIndex: number colIndex: number columnWidth: number - columnWidths: number[] // Добавляем массив всех ширин колонок + columnWidths: number[] isSelected: boolean - isInMultiSelection: boolean // Является ли ячейка частью множественного выделения - multiSelectionActive: boolean // Есть ли вообще активное множественное выделение - // Флаги, является ли ячейка на краю выделения - selectionTop?: boolean - selectionBottom?: boolean - selectionLeft?: boolean - selectionRight?: boolean + isInMultiSelection: boolean + multiSelectionActive: boolean isEditing: boolean - displayValue: string // Отображаемое значение (вычисленное) - rawValue: string // "Сырое" значение (формула) + displayValue: string + rawValue: string isModified: boolean - nextContentCol: number // индекс ближайшей занятой колонки - availablePx: number // суммарная ширина, куда можно расширяться + availablePx: number onCellClick: (row: number, col: number, event?: React.MouseEvent) => void onCellDoubleClick: (row: number, col: number) => void - onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования + onCellValueChange: (newValue: string) => void onCellReferenceClick?: ( sheetType: SheetType, row: number, col: number - ) => void // Клик для вставки ссылки - onCellMouseDown?: (row: number, col: number, event: React.MouseEvent) => void // Для начала drag selection - onCellMouseEnter?: (row: number, col: number) => void // Для drag selection - onCellMouseUp?: () => void // Для завершения drag selection - isInEditMode: boolean // Глобальный режим редактирования + ) => void + onCellMouseDown?: (row: number, col: number, event: React.MouseEvent) => void + onCellMouseEnter?: (row: number, col: number) => void + onCellMouseUp?: () => void + isInEditMode: boolean stopEditing: (save: boolean) => void setActiveSheet: (type: SheetType) => void activeCellRef: React.RefObject - style: CSSProperties // Добавляем style для react-window + style: CSSProperties mergedCellInfo?: { - // Информация об объединенных ячейках isMerged: boolean isMainCell: boolean rowSpan?: number colSpan?: number } - isCopied?: boolean // Является ли ячейка скопированной - isCut?: boolean // Является ли ячейка вырезанной - highlightColor?: string // Цвет подсветки для формул + isCopied?: boolean + isCut?: boolean + highlightColor?: string } export interface FormulaBarProps { diff --git a/src/component/TemplateManager/HeaderBar.tsx b/src/component/TemplateManager/HeaderBar.tsx index b6b5b59..d14c08b 100644 --- a/src/component/TemplateManager/HeaderBar.tsx +++ b/src/component/TemplateManager/HeaderBar.tsx @@ -1,5 +1,6 @@ +import { useSafeNavigation } from '@/hook/useSafeNavigation' import { Template } from '@/type/template' -import { ArrowLeft, FileText, Settings, Wrench } from 'lucide-react' +import { ArrowLeft, FileText, Loader2, Settings, Wrench } from 'lucide-react' import React from 'react' import { useNavigate } from 'react-router-dom' import { Button } from 'shared/ui/button' @@ -7,17 +8,42 @@ import { Button } from 'shared/ui/button' interface HeaderBarProps { template: Template onBack: () => void + hasUnsavedChanges?: boolean + isSaving?: boolean + onBeforeNavigate?: () => Promise } -export const HeaderBar: React.FC = ({ template, onBack }) => { +export const HeaderBar: React.FC = ({ + template, + onBack, + hasUnsavedChanges = false, + isSaving = false, + onBeforeNavigate, +}) => { const navigate = useNavigate() + // Хук для безопасной навигации + const { safeNavigate, isNavigating } = useSafeNavigation({ + onBeforeNavigate, + hasUnsavedChanges, + isSaving, + }) + return (
-
@@ -32,20 +58,40 @@ export const HeaderBar: React.FC = ({ template, onBack }) => {
diff --git a/src/component/TemplateManager/SpreadsheetViewer.tsx b/src/component/TemplateManager/SpreadsheetViewer.tsx index a194490..72bb0c3 100644 --- a/src/component/TemplateManager/SpreadsheetViewer.tsx +++ b/src/component/TemplateManager/SpreadsheetViewer.tsx @@ -1,16 +1,19 @@ import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet' -import { ExcelParseResult } from '@/service/fileApiService' -import React from 'react' +import React, { useCallback, useRef } from 'react' interface SpreadsheetViewerProps { data: Record - mergedCells: ExcelParseResult['mergedCells'] + mergedCells: any[] dataVersion: number - templateId: string | undefined - // Пропсы для ExcelUploadPanel + templateId: string excelFileName?: string - isExcelLoading?: boolean - onExcelFileChange?: (e: React.ChangeEvent) => void + isExcelLoading: boolean + onExcelFileChange: (e: React.ChangeEvent) => void + onAutoSaveStateChange?: (state: { + hasUnsavedChanges: boolean + isSaving: boolean + manualSave: () => Promise + }) => void } export const SpreadsheetViewer: React.FC = ({ @@ -21,16 +24,37 @@ export const SpreadsheetViewer: React.FC = ({ excelFileName, isExcelLoading, onExcelFileChange, -}) => ( -
- -
-) + onAutoSaveStateChange, +}) => { + // Используем ref для стабильного коллбека + const onAutoSaveStateChangeRef = useRef(onAutoSaveStateChange) + onAutoSaveStateChangeRef.current = onAutoSaveStateChange + + const handleEngineReady = useCallback((engine: any) => { + if (!engine || !onAutoSaveStateChangeRef.current) return + + // Передаем состояние родителю только один раз при инициализации движка + const state = { + hasUnsavedChanges: engine.hasUnsavedChanges || false, + isSaving: engine.isSaving || false, + manualSave: engine.manualSave || (() => Promise.resolve()), + } + + onAutoSaveStateChangeRef.current(state) + }, []) + + return ( +
+ +
+ ) +} diff --git a/src/component/TemplateManager/TemplateEditor.tsx b/src/component/TemplateManager/TemplateEditor.tsx index d5e6426..e4ccc38 100644 --- a/src/component/TemplateManager/TemplateEditor.tsx +++ b/src/component/TemplateManager/TemplateEditor.tsx @@ -5,7 +5,7 @@ import { parseExcelFile, } from '@/service/fileApiService' import { Template } from '@/type/template' -import React, { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { HeaderBar } from './HeaderBar' import { SpreadsheetViewer } from './SpreadsheetViewer' @@ -26,6 +26,17 @@ export const TemplateEditor: React.FC = ({ const [dataVersion, setDataVersion] = useState(0) const [serverFileName, setServerFileName] = useState() + // Состояние автосохранения таблиц + const [autoSaveState, setAutoSaveState] = useState<{ + hasUnsavedChanges: boolean + isSaving: boolean + manualSave: () => Promise + }>({ + hasUnsavedChanges: false, + isSaving: false, + manualSave: () => Promise.resolve(), + }) + const { updateTemplate } = useTemplateContext() useEffect(() => { @@ -108,9 +119,26 @@ export const TemplateEditor: React.FC = ({ } } + const handleAutoSaveStateChange = useCallback( + (state: { + hasUnsavedChanges: boolean + isSaving: boolean + manualSave: () => Promise + }) => { + setAutoSaveState(state) + }, + [] + ) + return (
- + = ({ excelFileName={serverFileName || editedTemplate.excelFile?.name} isExcelLoading={isLoading} onExcelFileChange={handleFileChange} + onAutoSaveStateChange={handleAutoSaveStateChange} />
) diff --git a/src/hook/useSafeNavigation.ts b/src/hook/useSafeNavigation.ts new file mode 100644 index 0000000..0df2ea6 --- /dev/null +++ b/src/hook/useSafeNavigation.ts @@ -0,0 +1,55 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +interface UseSafeNavigationOptions { + onBeforeNavigate?: () => Promise + hasUnsavedChanges?: boolean + isSaving?: boolean +} + +interface UseSafeNavigationReturn { + safeNavigate: (to: string | number) => Promise + isNavigating: boolean +} + +export function useSafeNavigation({ + onBeforeNavigate, + hasUnsavedChanges = false, + isSaving = false, +}: UseSafeNavigationOptions): UseSafeNavigationReturn { + const navigate = useNavigate() + const [isNavigating, setIsNavigating] = useState(false) + + const safeNavigate = useCallback( + async (to: string | number) => { + if (isNavigating || isSaving) return + + try { + setIsNavigating(true) + + // Если есть несохраненные изменения, сохраняем их перед переходом + if (hasUnsavedChanges && onBeforeNavigate) { + await onBeforeNavigate() + } + + // Выполняем навигацию + if (typeof to === 'string') { + navigate(to) + } else { + navigate(to) + } + } catch (error) { + console.error('Ошибка при навигации:', error) + // В случае ошибки не переходим + } finally { + setIsNavigating(false) + } + }, + [navigate, onBeforeNavigate, hasUnsavedChanges, isSaving, isNavigating] + ) + + return { + safeNavigate, + isNavigating, + } +} diff --git a/src/page/templates/edit/Page.tsx b/src/page/templates/edit/Page.tsx index 7c69ba6..66fedd1 100644 --- a/src/page/templates/edit/Page.tsx +++ b/src/page/templates/edit/Page.tsx @@ -1,5 +1,6 @@ import { TemplateEditor } from '@/component/TemplateManager/TemplateEditor' import { useTemplateContext } from '@/entity/template/model/TemplateContext' +import { useSafeNavigation } from '@/hook/useSafeNavigation' import { useNavigate, useParams } from 'react-router-dom' export const TemplateEditPage = () => { @@ -7,11 +8,16 @@ export const TemplateEditPage = () => { const { templates } = useTemplateContext() const navigate = useNavigate() + const { safeNavigate } = useSafeNavigation({ + hasUnsavedChanges: false, + isSaving: false, + }) + const template = templates.find(t => t.id === templateId) if (!template) { navigate('/templates', { replace: true }) return null } - return navigate(-1)} /> + return safeNavigate(-1)} /> } diff --git a/src/page/templates/elements/Page.tsx b/src/page/templates/elements/Page.tsx index 19c8060..ddb8f58 100644 --- a/src/page/templates/elements/Page.tsx +++ b/src/page/templates/elements/Page.tsx @@ -1,8 +1,9 @@ import { ElementConstructor } from '@/component/TemplateManager/ElementConstructor' import { useTemplateContext } from '@/entity/template/model/TemplateContext' import { useDelayedSave } from '@/hook/useDelayedSave' +import { useSafeNavigation } from '@/hook/useSafeNavigation' import { FormLayoutSettings, Template, TemplateElement } from '@/type/template' -import { ArrowLeft, FileText, Settings, Wrench } from 'lucide-react' +import { ArrowLeft, FileText, Loader2, Settings, Wrench } from 'lucide-react' import { FC, useCallback, useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Button } from 'shared/ui/button' @@ -51,6 +52,13 @@ export const ElementsCreation: FC = () => { delay: 2000, // 2 секунды задержки }) + // Хук для безопасной навигации + const { safeNavigate, isNavigating } = useSafeNavigation({ + onBeforeNavigate: manualSave, + hasUnsavedChanges, + isSaving, + }) + const handleElementAdd = async (element: TemplateElement) => { if (!selectedTemplate) { return @@ -172,9 +180,14 @@ export const ElementsCreation: FC = () => {
@@ -189,22 +202,44 @@ export const ElementsCreation: FC = () => {
diff --git a/src/page/templates/protocols/Page.tsx b/src/page/templates/protocols/Page.tsx index df60971..970a05a 100644 --- a/src/page/templates/protocols/Page.tsx +++ b/src/page/templates/protocols/Page.tsx @@ -1,11 +1,19 @@ import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet' import { getElementDefinition } from '@/entity/element/model/interface' import { useTemplateContext } from '@/entity/template/model/TemplateContext' +import { useSafeNavigation } from '@/hook/useSafeNavigation' import { cellAddressToCoordinates } from '@/lib/cell-utils' import { useToast } from '@/lib/hooks/useToast' import { getLatestFileForTemplate } from '@/service/fileApiService' import { Template, TemplateElement } from '@/type/template' -import { ArrowLeft, FileText, Grid, Save, Settings, Wrench } from 'lucide-react' +import { + ArrowLeft, + FileText, + Loader2, + Save, + Settings, + Wrench, +} from 'lucide-react' import { FC, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Button } from 'shared/ui/button' @@ -20,10 +28,17 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { const [formData, setFormData] = useState>({}) const [showSpreadsheet, setShowSpreadsheet] = useState(false) const [isInitialized, setIsInitialized] = useState(false) + const [isSavingProtocol, setIsSavingProtocol] = useState(false) const engineRef = useRef(null) const toast = useToast() const navigate = useNavigate() + // Хук для безопасной навигации (здесь нет автосохранения формы, только состояние) + const { safeNavigate, isNavigating } = useSafeNavigation({ + hasUnsavedChanges: false, // Форма протокола не требует автосохранения + isSaving: isSavingProtocol, + }) + // Инициализация начальных значений формы (только один раз) useEffect(() => { if (!isInitialized && template.elements.length > 0) { @@ -108,6 +123,8 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { onSave(data) try { + setIsSavingProtocol(true) + if (!engineRef.current) { toast.error('Движок ещё не инициализирован') return @@ -195,6 +212,8 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { } catch (err) { console.error('Ошибка при создании протокола', err) toast.error('Ошибка при создании протокола') + } finally { + setIsSavingProtocol(false) } } @@ -241,8 +260,17 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => {
{/* Левая часть: кнопка назад и заголовок */}
-
@@ -256,18 +284,21 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => { {/* Центрированные кнопки */}
-
{/* Кнопки справа */} @@ -275,20 +306,40 @@ const ProtocolForm: FC = ({ template, onSave, onBack }) => {
diff --git a/tailwind.config.js b/tailwind.config.js index 950fea5..c66ff06 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,11 @@ /** @type {import('tailwindcss').Config} */ export default { darkMode: ['class'], - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + './shared/**/*.{js,ts,jsx,tsx}', + ], theme: { extend: { colors: {