сохранение при смене страниц

This commit is contained in:
2025-07-28 18:36:02 +03:00
parent 27b9b79c60
commit 4bc8d5753f
14 changed files with 617 additions and 473 deletions

View File

@@ -60,22 +60,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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
@@ -1034,6 +1018,39 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
autoSaveOptions
)
// Используем ref для стабильного API
const engineAPIRef = useRef<any>(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 для более плавного обновления

View File

@@ -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,21 +27,20 @@ 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 для подсветки и обычного состояния
// Определяем стили ячейки
const getCellStyles = (): { className: string; style: CSSProperties } => {
let className = 'border border-border'
// Базовый style для ячейки
let cellStyle: CSSProperties = {
...style,
overflow: 'visible',
@@ -54,44 +48,27 @@ export const Cell = memo(
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)', // голубоватый оттенок
}
cellStyle.backgroundColor = 'rgba(59,130,246,0.08)'
}
// Стили для скопированных/вырезанных ячеек
if (isCopied || isCut) {
cellStyle = {
...cellStyle,
border: '2px dashed hsl(var(--primary))',
backgroundColor: isCut
cellStyle.border = '2px dashed hsl(var(--primary))'
cellStyle.backgroundColor = isCut
? 'rgba(239, 68, 68, 0.1)'
: 'rgba(34, 197, 94, 0.1)', // красноватый для вырезанных, зеленоватый для скопированных
}
: 'rgba(34, 197, 94, 0.1)'
}
// Отладка стилей для измененных ячеек
if (isModified) {
// console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, //{
// className,
// isModified,
// isSelected,
// displayValue,
// highlightColor,
//})
}
// Для объединенных ячеек нужно рассчитать правильные размеры
// Объединенные ячейки
if (
mergedCellInfo?.isMainCell &&
mergedCellInfo.rowSpan &&
@@ -99,22 +76,15 @@ export const Cell = memo(
) {
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 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]})`
@@ -122,67 +92,33 @@ export const Cell = memo(
} else if (isSelected) {
backgroundColor = 'hsl(var(--accent))'
} else if (isModified) {
backgroundColor =
'#fef08a' /* тот же жёлтый оттенок, что и bg-yellow-200 */
backgroundColor = '#fef08a'
}
cellStyle = {
...cellStyle,
width: totalWidth,
height: totalHeight,
zIndex: 10, // Поднимаем выше обычных ячеек
zIndex: 10,
backgroundColor,
}
}
// Используем обычное значение displayValue для всех ячеек, включая объединенные
const cellDisplayValue = displayValue
return { className, style: cellStyle }
}
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 (
<div
className={className}
@@ -254,57 +224,23 @@ export const Cell = memo(
<input
ref={activeCellRef as React.RefObject<HTMLInputElement>}
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"
/>
) : (
// Контейнер, который допускает перепрыгивание текста, если впереди есть свободное место
<div
className="relative flex h-full w-full items-center px-2"
style={{
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' }
: {}),
}}
style={contentStyles}
>
<div
className="absolute left-1 top-0 flex h-full items-center"
style={{
width:
availablePx > columnWidth
? availablePx - 8
: columnWidth - 16,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
pointerEvents: 'none', // чтобы клики шли в "родную" ячейку
zIndex: 1,
}}
style={textStyles}
>
{cellDisplayValue}
{displayValue}
</div>
</div>
)}

View File

@@ -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}
/>

View File

@@ -185,6 +185,16 @@ export const FormulaBar = memo(
</div>
)}
{/* ExcelUploadPanel */}
{onExcelFileChange && (
<ExcelUploadPanel
fileName={excelFileName}
isLoading={isExcelLoading}
onUploadClick={() => {}}
onFileChange={onExcelFileChange}
/>
)}
{/* Кнопка сохранения */}
{onManualSave && (
<button
@@ -215,16 +225,6 @@ export const FormulaBar = memo(
</span>
</button>
)}
{/* ExcelUploadPanel */}
{onExcelFileChange && (
<ExcelUploadPanel
fileName={excelFileName}
isLoading={isExcelLoading}
onUploadClick={() => {}}
onFileChange={onExcelFileChange}
/>
)}
</div>
</div>
</div>

View File

@@ -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<any>
// --- 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<HTMLDivElement>(null)
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
// Состояние текущего скролла грида
const [scrollPos, setScrollPos] = useState({ left: 0, top: 0 })
useEffect(() => {
@@ -100,9 +85,12 @@ export const Spreadsheet = ({
return () => resizeObserver.disconnect()
}, [])
// Вычисляем выделенные строки и колонки для активного листа
const getSelectedRowsAndCols = () => {
const selectedRows = new Set<number>()
const selectedCols = new Set<number>()
if (sheetType === activeSheet && multiSelection.length > 0) {
if (isActiveSheet && multiSelection.length > 0) {
multiSelection.forEach(sel => {
const top = Math.min(sel.startRow, sel.endRow)
const bottom = Math.max(sel.startRow, sel.endRow)
@@ -113,6 +101,84 @@ export const Spreadsheet = ({
})
}
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 (
<div
style={{
position: 'absolute',
top: topPx,
left: leftPx,
width: widthPx,
height: heightPx,
border: '2px solid hsl(var(--primary))',
pointerEvents: 'none',
boxSizing: 'border-box',
zIndex: 5,
}}
/>
)
}
return (
<div
ref={parentRef}
@@ -131,12 +197,10 @@ export const Spreadsheet = ({
style={{ userSelect: isEditing ? 'text' : 'none' }}
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 ${
@@ -159,13 +223,16 @@ export const Spreadsheet = ({
<div>
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => {
const isActiveRow =
(selectedCell?.row === rowIndex &&
activeSheet === sheetType) ||
(selectedCell?.row === rowIndex && isActiveSheet) ||
selectedRows.has(rowIndex)
return (
<div
key={rowIndex}
className={`flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium ${isActiveRow ? 'bg-primary/15 font-medium text-foreground' : 'text-muted-foreground'}`}
className={`flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium ${
isActiveRow
? 'bg-primary/15 font-medium text-foreground'
: 'text-muted-foreground'
}`}
>
{rowIndex + 1}
</div>
@@ -184,38 +251,25 @@ export const Spreadsheet = ({
className="column-headers overflow-x-hidden"
>
<div className="flex">
{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 (
<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 ${isActiveColumn ? 'bg-primary/15 font-medium text-foreground' : 'text-muted-foreground'}`}
style={{ width: widths[i] }}
key={colIndex}
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 ${
isActiveColumn
? 'bg-primary/15 font-medium text-foreground'
: 'text-muted-foreground'
}`}
style={{ width: columnWidths[colIndex] }}
>
{getColumnLabel(i)}
{getColumnLabel(colIndex)}
<div
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
onMouseDown={handleMouseDown}
onMouseDown={handleColumnResizeStart(colIndex)}
/>
</div>
)
@@ -225,60 +279,7 @@ export const Spreadsheet = ({
{/* Grid */}
<div className="relative min-h-0 flex-1">
{/* 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 (
<div
style={{
position: 'absolute',
top: topPx,
left: leftPx,
width: widthPx,
height: heightPx,
border: '2px solid hsl(var(--primary))',
pointerEvents: 'none',
boxSizing: 'border-box',
transition: 'none',
zIndex: 5,
}}
/>
)
})()}
{renderSelectionOverlay()}
<Grid
ref={(el: any) => {
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}

View File

@@ -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<HTMLInputElement | null>
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 {

View File

@@ -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<void>
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
export const HeaderBar: React.FC<HeaderBarProps> = ({
template,
onBack,
hasUnsavedChanges = false,
isSaving = false,
onBeforeNavigate,
}) => {
const navigate = useNavigate()
// Хук для безопасной навигации
const { safeNavigate, isNavigating } = useSafeNavigation({
onBeforeNavigate,
hasUnsavedChanges,
isSaving,
})
return (
<div className="flex-shrink-0 border-b bg-card px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<Button
variant="ghost"
size="icon"
onClick={onBack}
disabled={isNavigating || isSaving}
>
{isNavigating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5" />
@@ -32,20 +58,40 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
onClick={() => safeNavigate(`/templates/${template.id}/elements`)}
disabled={isNavigating || isSaving}
className="flex items-center gap-1 hover:bg-muted"
>
{isNavigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Сохранение...</span>
</>
) : (
<>
<Wrench className="h-4 w-4" />
<span className="text-sm">Редактор интерфейса</span>
</>
)}
</Button>
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${template.id}/protocols`)}
onClick={() => safeNavigate(`/templates/${template.id}/protocols`)}
disabled={isNavigating || isSaving}
className="flex items-center gap-1 hover:bg-muted"
>
{isNavigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Сохранение...</span>
</>
) : (
<>
<FileText className="h-4 w-4" />
<span className="text-sm">Создать протокол</span>
</>
)}
</Button>
</div>
</div>

View File

@@ -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<string, any>
mergedCells: ExcelParseResult['mergedCells']
mergedCells: any[]
dataVersion: number
templateId: string | undefined
// Пропсы для ExcelUploadPanel
templateId: string
excelFileName?: string
isExcelLoading?: boolean
onExcelFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
isExcelLoading: boolean
onExcelFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void
onAutoSaveStateChange?: (state: {
hasUnsavedChanges: boolean
isSaving: boolean
manualSave: () => Promise<void>
}) => void
}
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
@@ -21,7 +24,26 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
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 (
<div className="min-h-0 flex-1">
<DualSpreadsheet
key={dataVersion}
@@ -31,6 +53,8 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
excelFileName={excelFileName}
isExcelLoading={isExcelLoading}
onExcelFileChange={onExcelFileChange}
onEngineReady={handleEngineReady}
/>
</div>
)
)
}

View File

@@ -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<TemplateEditorProps> = ({
const [dataVersion, setDataVersion] = useState(0)
const [serverFileName, setServerFileName] = useState<string | undefined>()
// Состояние автосохранения таблиц
const [autoSaveState, setAutoSaveState] = useState<{
hasUnsavedChanges: boolean
isSaving: boolean
manualSave: () => Promise<void>
}>({
hasUnsavedChanges: false,
isSaving: false,
manualSave: () => Promise.resolve(),
})
const { updateTemplate } = useTemplateContext()
useEffect(() => {
@@ -108,9 +119,26 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
}
}
const handleAutoSaveStateChange = useCallback(
(state: {
hasUnsavedChanges: boolean
isSaving: boolean
manualSave: () => Promise<void>
}) => {
setAutoSaveState(state)
},
[]
)
return (
<div className="flex h-screen flex-col bg-background">
<HeaderBar template={editedTemplate} onBack={onBack} />
<HeaderBar
template={editedTemplate}
onBack={onBack}
isSaving={isLoading || autoSaveState.isSaving}
hasUnsavedChanges={autoSaveState.hasUnsavedChanges}
onBeforeNavigate={autoSaveState.manualSave}
/>
<SpreadsheetViewer
data={excelData}
mergedCells={editedTemplate.mergedCells || []}
@@ -119,6 +147,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
excelFileName={serverFileName || editedTemplate.excelFile?.name}
isExcelLoading={isLoading}
onExcelFileChange={handleFileChange}
onAutoSaveStateChange={handleAutoSaveStateChange}
/>
</div>
)

View File

@@ -0,0 +1,55 @@
import { useCallback, useState } from 'react'
import { useNavigate } from 'react-router-dom'
interface UseSafeNavigationOptions {
onBeforeNavigate?: () => Promise<void>
hasUnsavedChanges?: boolean
isSaving?: boolean
}
interface UseSafeNavigationReturn {
safeNavigate: (to: string | number) => Promise<void>
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,
}
}

View File

@@ -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 <TemplateEditor template={template} onBack={() => navigate(-1)} />
return <TemplateEditor template={template} onBack={() => safeNavigate(-1)} />
}

View File

@@ -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 = () => {
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/templates')}
onClick={() => safeNavigate('/templates')}
disabled={isNavigating || isSaving}
>
{isNavigating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<div className="flex items-center space-x-2">
<Wrench className="h-5 w-5" />
@@ -189,22 +202,44 @@ export const ElementsCreation: FC = () => {
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${selectedTemplate.id}/edit`)}
onClick={() =>
safeNavigate(`/templates/${selectedTemplate.id}/edit`)
}
disabled={isNavigating || isSaving}
className="flex items-center gap-1 hover:bg-muted"
>
{isNavigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Сохранение...</span>
</>
) : (
<>
<Settings className="h-4 w-4" />
<span className="text-sm">Настройки шаблона</span>
</>
)}
</Button>
<Button
variant="link"
size="sm"
onClick={() =>
navigate(`/templates/${selectedTemplate.id}/protocols`)
safeNavigate(`/templates/${selectedTemplate.id}/protocols`)
}
disabled={isNavigating || isSaving}
className="flex items-center gap-1 hover:bg-muted"
>
{isNavigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Сохранение...</span>
</>
) : (
<>
<FileText className="h-4 w-4" />
<span className="text-sm">Создать протокол</span>
</>
)}
</Button>
</div>
</div>

View File

@@ -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<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [formData, setFormData] = useState<Record<string, any>>({})
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const [isSavingProtocol, setIsSavingProtocol] = useState(false)
const engineRef = useRef<any>(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<ProtocolFormProps> = ({ template, onSave, onBack }) => {
onSave(data)
try {
setIsSavingProtocol(true)
if (!engineRef.current) {
toast.error('Движок ещё не инициализирован')
return
@@ -195,6 +212,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
} catch (err) {
console.error('Ошибка при создании протокола', err)
toast.error('Ошибка при создании протокола')
} finally {
setIsSavingProtocol(false)
}
}
@@ -241,8 +260,17 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<div className="relative flex items-center">
{/* Левая часть: кнопка назад и заголовок */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<Button
variant="ghost"
size="icon"
onClick={onBack}
disabled={isNavigating || isSavingProtocol}
>
{isNavigating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5" />
@@ -256,18 +284,21 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
{/* Центрированные кнопки */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-2">
<Button
variant={showSpreadsheet ? 'default' : 'outline'}
size="icon"
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
aria-label={
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
}
variant="outline"
onClick={handleSave}
disabled={!canSave || isSavingProtocol}
>
<Grid className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleSave} disabled={!canSave}>
{isSavingProtocol ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Сохранение...
</>
) : (
<>
<Save className="h-4 w-4" />
Сохранить протокол
</>
)}
</Button>
</div>
{/* Кнопки справа */}
@@ -275,20 +306,40 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${template.id}/edit`)}
onClick={() => safeNavigate(`/templates/${template.id}/edit`)}
disabled={isNavigating || isSavingProtocol}
className="flex items-center gap-1 hover:bg-muted"
>
{isNavigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Переход...</span>
</>
) : (
<>
<Settings className="h-4 w-4" />
<span className="text-sm">Редактор шаблона</span>
</>
)}
</Button>
<Button
variant="link"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
onClick={() => safeNavigate(`/templates/${template.id}/elements`)}
disabled={isNavigating || isSavingProtocol}
className="flex items-center gap-1 hover:bg-muted"
>
{isNavigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Переход...</span>
</>
) : (
<>
<Wrench className="h-4 w-4" />
<span className="text-sm">Редактор интерфейса</span>
</>
)}
</Button>
</div>
</div>

View File

@@ -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: {