сохранение при смене страниц
This commit is contained in:
@@ -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 для более плавного обновления
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,17 +85,98 @@ export const Spreadsheet = ({
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
const selectedRows = new Set<number>()
|
||||
const selectedCols = new Set<number>()
|
||||
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<number>()
|
||||
const selectedCols = new Set<number>()
|
||||
|
||||
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 (
|
||||
<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 (
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<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"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
<span className="text-sm">Редактор интерфейса</span>
|
||||
{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"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Создать протокол</span>
|
||||
{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>
|
||||
|
||||
@@ -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,16 +24,37 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
||||
excelFileName,
|
||||
isExcelLoading,
|
||||
onExcelFileChange,
|
||||
}) => (
|
||||
<div className="min-h-0 flex-1">
|
||||
<DualSpreadsheet
|
||||
key={dataVersion}
|
||||
templateData={{ L: data }}
|
||||
mergedCells={mergedCells}
|
||||
templateId={templateId}
|
||||
excelFileName={excelFileName}
|
||||
isExcelLoading={isExcelLoading}
|
||||
onExcelFileChange={onExcelFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
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}
|
||||
templateData={{ L: data }}
|
||||
mergedCells={mergedCells}
|
||||
templateId={templateId}
|
||||
excelFileName={excelFileName}
|
||||
isExcelLoading={isExcelLoading}
|
||||
onExcelFileChange={onExcelFileChange}
|
||||
onEngineReady={handleEngineReady}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
55
src/hook/useSafeNavigation.ts
Normal file
55
src/hook/useSafeNavigation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)} />
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{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"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="text-sm">Настройки шаблона</span>
|
||||
{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"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">Создать протокол</span>
|
||||
{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>
|
||||
|
||||
@@ -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}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<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}>
|
||||
<Save className="h-4 w-4" />
|
||||
Сохранить протокол
|
||||
{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"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="text-sm">Редактор шаблона</span>
|
||||
{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"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
<span className="text-sm">Редактор интерфейса</span>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user