сохранение при смене страниц
This commit is contained in:
@@ -60,22 +60,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
initialData: templateData || {},
|
initialData: templateData || {},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Мемоизируем объект для передачи родительскому компоненту
|
|
||||||
const engineAPI = useMemo(
|
|
||||||
() => ({
|
|
||||||
...multiSheetEngine,
|
|
||||||
updateRevision,
|
|
||||||
}),
|
|
||||||
[multiSheetEngine, updateRevision]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Передаем методы движка родительскому компоненту
|
|
||||||
useEffect(() => {
|
|
||||||
if (onEngineReady) {
|
|
||||||
onEngineReady(engineAPI)
|
|
||||||
}
|
|
||||||
}, [onEngineReady, engineAPI])
|
|
||||||
|
|
||||||
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
|
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
|
||||||
const [selectedCell, setSelectedCell] = useState<{
|
const [selectedCell, setSelectedCell] = useState<{
|
||||||
row: number
|
row: number
|
||||||
@@ -1034,6 +1018,39 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
autoSaveOptions
|
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) => {
|
const handleDividerDrag = useCallback((newLeftPercentage: number) => {
|
||||||
// Используем requestAnimationFrame для более плавного обновления
|
// Используем requestAnimationFrame для более плавного обновления
|
||||||
|
|||||||
@@ -11,15 +11,10 @@ export const Cell = memo(
|
|||||||
isSelected,
|
isSelected,
|
||||||
isInMultiSelection,
|
isInMultiSelection,
|
||||||
multiSelectionActive,
|
multiSelectionActive,
|
||||||
selectionTop = false,
|
|
||||||
selectionBottom = false,
|
|
||||||
selectionLeft = false,
|
|
||||||
selectionRight = false,
|
|
||||||
isEditing,
|
isEditing,
|
||||||
displayValue,
|
displayValue,
|
||||||
rawValue,
|
rawValue,
|
||||||
isModified,
|
isModified,
|
||||||
nextContentCol,
|
|
||||||
availablePx,
|
availablePx,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
onCellDoubleClick,
|
onCellDoubleClick,
|
||||||
@@ -32,157 +27,98 @@ export const Cell = memo(
|
|||||||
stopEditing,
|
stopEditing,
|
||||||
setActiveSheet,
|
setActiveSheet,
|
||||||
activeCellRef,
|
activeCellRef,
|
||||||
style, // Добавляем style для react-window
|
style,
|
||||||
mergedCellInfo,
|
mergedCellInfo,
|
||||||
highlightColor, // Цвет подсветки для формул
|
highlightColor,
|
||||||
isCopied, // Является ли ячейка скопированной
|
isCopied,
|
||||||
isCut, // Является ли ячейка вырезанной
|
isCut,
|
||||||
}: CellProps) => {
|
}: CellProps) => {
|
||||||
// Если ячейка объединена, но не является основной, не рендерим её
|
// Если ячейка объединена, но не является основной, не рендерим её
|
||||||
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
|
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Определяем классы CSS для подсветки и обычного состояния
|
// Определяем стили ячейки
|
||||||
let className = 'border border-border'
|
const getCellStyles = (): { className: string; style: CSSProperties } => {
|
||||||
|
let className = 'border border-border'
|
||||||
// Базовый style для ячейки
|
let cellStyle: CSSProperties = {
|
||||||
let cellStyle: CSSProperties = {
|
...style,
|
||||||
...style,
|
overflow: 'visible',
|
||||||
overflow: 'visible',
|
padding: 0,
|
||||||
padding: 0,
|
userSelect: 'none',
|
||||||
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 totalHeight = ROW_HEIGHT * rowSpan
|
|
||||||
|
|
||||||
// Определяем цвет фона для объединенной ячейки
|
|
||||||
let backgroundColor = 'white'
|
|
||||||
if (highlightColor) {
|
if (highlightColor) {
|
||||||
// Если есть подсветка формулы, используем её
|
className += ` ${highlightColor}`
|
||||||
const bgMatch = highlightColor.match(/bg-(\w+)-(\d+)/)
|
} else if (isModified && !mergedCellInfo?.isMerged) {
|
||||||
if (bgMatch) {
|
className += ' bg-yellow-200'
|
||||||
backgroundColor = `var(--${bgMatch[1]}-${bgMatch[2]})`
|
|
||||||
}
|
|
||||||
} else if (isSelected) {
|
|
||||||
backgroundColor = 'hsl(var(--accent))'
|
|
||||||
} else if (isModified) {
|
|
||||||
backgroundColor =
|
|
||||||
'#fef08a' /* тот же жёлтый оттенок, что и bg-yellow-200 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cellStyle = {
|
// Множественное выделение
|
||||||
...cellStyle,
|
if (isInMultiSelection) {
|
||||||
width: totalWidth,
|
cellStyle.backgroundColor = 'rgba(59,130,246,0.08)'
|
||||||
height: totalHeight,
|
|
||||||
zIndex: 10, // Поднимаем выше обычных ячеек
|
|
||||||
backgroundColor,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Стили для скопированных/вырезанных ячеек
|
||||||
|
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 { className, style: cellStyle } = getCellStyles()
|
||||||
const cellDisplayValue = displayValue
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
// console.log('🖱️ Cell клик:', {
|
|
||||||
// rowIndex,
|
|
||||||
// colIndex,
|
|
||||||
// sheetType,
|
|
||||||
// isInEditMode,
|
|
||||||
// isSelected,
|
|
||||||
//hasReferenceHandler: !!onCellReferenceClick,
|
|
||||||
//})
|
|
||||||
|
|
||||||
// Если мы в режиме редактирования, вставка была обработана на mousedown.
|
|
||||||
// Останавливаем всплытие, чтобы родительский Spreadsheet не сменил активный лист.
|
|
||||||
if (isInEditMode) {
|
if (isInEditMode) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обычная логика клика (выделение ячейки)
|
|
||||||
// console.log('📍 Обычный клик - выбираем ячейку:', {
|
|
||||||
// rowIndex,
|
|
||||||
// colIndex,
|
|
||||||
// sheetType,
|
|
||||||
//})
|
|
||||||
|
|
||||||
// Подсказка для пользователя
|
|
||||||
if (!isInEditMode) {
|
|
||||||
// console.log(
|
|
||||||
// '💡 Чтобы вставить ссылку на ячейку: 1) Выберите ячейку 2) Нажмите F2 или Enter для редактирования 3) Кликните на нужную ячейку'
|
|
||||||
//)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCellClick(rowIndex, colIndex, e)
|
onCellClick(rowIndex, colIndex, e)
|
||||||
},
|
},
|
||||||
[
|
[isInEditMode, onCellClick, rowIndex, colIndex]
|
||||||
setActiveSheet,
|
|
||||||
sheetType,
|
|
||||||
onCellClick,
|
|
||||||
onCellReferenceClick,
|
|
||||||
rowIndex,
|
|
||||||
colIndex,
|
|
||||||
isInEditMode,
|
|
||||||
isSelected,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
@@ -193,24 +129,18 @@ export const Cell = memo(
|
|||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (!isInEditMode) {
|
if (!isInEditMode) {
|
||||||
// блокируем стандартное выделение текста браузером
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем mousedown, чтобы сработать раньше blur FormulaBar
|
// Вставка ссылки в режиме редактирования
|
||||||
if (isInEditMode && onCellReferenceClick && !isSelected) {
|
if (isInEditMode && onCellReferenceClick && !isSelected) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// console.log('🖱️ mousedown – вставка ссылки до blur:', {
|
|
||||||
// rowIndex,
|
|
||||||
// colIndex,
|
|
||||||
// sheetType,
|
|
||||||
//})
|
|
||||||
onCellReferenceClick(sheetType, rowIndex, colIndex)
|
onCellReferenceClick(sheetType, rowIndex, colIndex)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик для начала drag selection
|
// Drag selection
|
||||||
if (onCellMouseDown) {
|
if (onCellMouseDown) {
|
||||||
setActiveSheet(sheetType)
|
setActiveSheet(sheetType)
|
||||||
onCellMouseDown(rowIndex, colIndex, e)
|
onCellMouseDown(rowIndex, colIndex, e)
|
||||||
@@ -229,17 +159,57 @@ export const Cell = memo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
if (onCellMouseEnter) {
|
onCellMouseEnter?.(rowIndex, colIndex)
|
||||||
onCellMouseEnter(rowIndex, colIndex)
|
|
||||||
}
|
|
||||||
}, [onCellMouseEnter, rowIndex, colIndex])
|
}, [onCellMouseEnter, rowIndex, colIndex])
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
if (onCellMouseUp) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
@@ -254,57 +224,23 @@ export const Cell = memo(
|
|||||||
<input
|
<input
|
||||||
ref={activeCellRef as React.RefObject<HTMLInputElement>}
|
ref={activeCellRef as React.RefObject<HTMLInputElement>}
|
||||||
type="text"
|
type="text"
|
||||||
value={rawValue} // Во время редактирования показываем текущее редактируемое значение
|
value={rawValue}
|
||||||
onChange={e => onCellValueChange(e.target.value)}
|
onChange={e => onCellValueChange(e.target.value)}
|
||||||
style={{ width: columnWidth - 4 }}
|
style={{ width: columnWidth - 4 }}
|
||||||
onBlur={e => {
|
onBlur={handleInputBlur}
|
||||||
// Проверяем, не переходит ли фокус в formula bar
|
onKeyDown={handleInputKeyDown}
|
||||||
const relatedTarget = e.relatedTarget as HTMLElement
|
|
||||||
if (relatedTarget && relatedTarget.closest('.formula-bar')) {
|
|
||||||
return // Не завершаем редактирование
|
|
||||||
}
|
|
||||||
stopEditing(true)
|
|
||||||
}}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
stopEditing(true)
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
stopEditing(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute left-0 top-0 z-20 box-border h-full border-2 border-primary px-2 py-1"
|
className="absolute left-0 top-0 z-20 box-border h-full border-2 border-primary px-2 py-1"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Контейнер, который допускает перепрыгивание текста, если впереди есть свободное место
|
|
||||||
<div
|
<div
|
||||||
className="relative flex h-full w-full items-center px-2"
|
className="relative flex h-full w-full items-center px-2"
|
||||||
style={{
|
style={contentStyles}
|
||||||
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' }
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute left-1 top-0 flex h-full items-center"
|
className="absolute left-1 top-0 flex h-full items-center"
|
||||||
style={{
|
style={textStyles}
|
||||||
width:
|
|
||||||
availablePx > columnWidth
|
|
||||||
? availablePx - 8
|
|
||||||
: columnWidth - 16,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
pointerEvents: 'none', // чтобы клики шли в "родную" ячейку
|
|
||||||
zIndex: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{cellDisplayValue}
|
{displayValue}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { CellRendererProps } from '../types'
|
|||||||
import { findMergedCellInfo, isInAnySelection } from '../utils'
|
import { findMergedCellInfo, isInAnySelection } from '../utils'
|
||||||
import { Cell } from './Cell'
|
import { Cell } from './Cell'
|
||||||
|
|
||||||
// Рендер-проп для ячеек грида
|
|
||||||
export const CellRenderer = ({
|
export const CellRenderer = ({
|
||||||
columnIndex,
|
columnIndex,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
@@ -34,55 +33,25 @@ export const CellRenderer = ({
|
|||||||
activeCellInputRef,
|
activeCellInputRef,
|
||||||
} = data
|
} = data
|
||||||
|
|
||||||
|
const isActiveSheet = activeSheet === sheetType
|
||||||
const isCellSelected =
|
const isCellSelected =
|
||||||
selectedCell?.row === rowIndex &&
|
selectedCell?.row === rowIndex &&
|
||||||
selectedCell?.col === columnIndex &&
|
selectedCell?.col === columnIndex &&
|
||||||
activeSheet === sheetType
|
isActiveSheet
|
||||||
|
|
||||||
// Проверяем, является ли ячейка частью множественного выделения
|
|
||||||
const isInMultiSelection =
|
const isInMultiSelection =
|
||||||
sheetType === activeSheet &&
|
isActiveSheet && isInAnySelection(rowIndex, columnIndex, multiSelection)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const multiSelectionActive = isActiveSheet && multiSelection.length > 0
|
||||||
const isCellEditing = isCellSelected && isEditing
|
const isCellEditing = isCellSelected && isEditing
|
||||||
|
|
||||||
const cellKey = `${rowIndex}-${columnIndex}`
|
const cellKey = `${rowIndex}-${columnIndex}`
|
||||||
const cellData = cachedData[cellKey]
|
const cellData = cachedData[cellKey]
|
||||||
|
|
||||||
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
|
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
|
||||||
const displayValue = isCellEditing
|
const displayValue = isCellEditing ? '' : cellData?.displayValue || ''
|
||||||
? '' // Не показывать вычисленное значение во время редактирования
|
|
||||||
: cellData?.displayValue || ''
|
|
||||||
|
|
||||||
// Проверяем, подсвечена ли эта ячейка
|
// Подсветка ячеек для формул
|
||||||
const currentSheetName = sheetType === 'report' ? 'L' : 'R'
|
const currentSheetName = sheetType === 'report' ? 'L' : 'R'
|
||||||
const highlightedCell = highlightedCells.find(
|
const highlightedCell = highlightedCells.find(
|
||||||
highlight =>
|
highlight =>
|
||||||
@@ -90,23 +59,8 @@ export const CellRenderer = ({
|
|||||||
highlight.row === rowIndex &&
|
highlight.row === rowIndex &&
|
||||||
highlight.col === columnIndex
|
highlight.col === columnIndex
|
||||||
)
|
)
|
||||||
const highlightColor = highlightedCell?.color
|
|
||||||
|
|
||||||
// Отладка для измененных ячеек
|
// Объединенные ячейки только для листа отчета
|
||||||
if (cellData?.isModified) {
|
|
||||||
// console.log(
|
|
||||||
// `🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
|
|
||||||
// {
|
|
||||||
// cellKey,
|
|
||||||
// isModified: cellData.isModified,
|
|
||||||
// displayValue,
|
|
||||||
// rawValue,
|
|
||||||
// highlightColor,
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем информацию об объединенных ячейках только для листа отчета
|
|
||||||
const mergedCellInfo =
|
const mergedCellInfo =
|
||||||
sheetType === 'report' && mergedCells
|
sheetType === 'report' && mergedCells
|
||||||
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
|
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
|
||||||
@@ -118,14 +72,12 @@ export const CellRenderer = ({
|
|||||||
isMainCell: mergedCellInfo.isMainCell,
|
isMainCell: mergedCellInfo.isMainCell,
|
||||||
rowSpan: mergedCellInfo.spans?.rowSpan,
|
rowSpan: mergedCellInfo.spans?.rowSpan,
|
||||||
colSpan: mergedCellInfo.spans?.colSpan,
|
colSpan: mergedCellInfo.spans?.colSpan,
|
||||||
// Убираем mergedValue - будем использовать актуальные данные из движка
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
// Проверяем, является ли ячейка скопированной
|
// Скопированные/вырезанные ячейки
|
||||||
const isCopied = Boolean(
|
const isCopied = Boolean(
|
||||||
copiedCells &&
|
copiedCells?.sourceSheet === sheetType &&
|
||||||
copiedCells.sourceSheet === sheetType &&
|
|
||||||
copiedCells.data.some(
|
copiedCells.data.some(
|
||||||
cell => cell.row === rowIndex && cell.col === columnIndex
|
cell => cell.row === rowIndex && cell.col === columnIndex
|
||||||
)
|
)
|
||||||
@@ -143,15 +95,10 @@ export const CellRenderer = ({
|
|||||||
isSelected={isCellSelected}
|
isSelected={isCellSelected}
|
||||||
isInMultiSelection={isInMultiSelection}
|
isInMultiSelection={isInMultiSelection}
|
||||||
multiSelectionActive={multiSelectionActive}
|
multiSelectionActive={multiSelectionActive}
|
||||||
selectionTop={selectionTop}
|
|
||||||
selectionBottom={selectionBottom}
|
|
||||||
selectionLeft={selectionLeft}
|
|
||||||
selectionRight={selectionRight}
|
|
||||||
isEditing={isCellEditing}
|
isEditing={isCellEditing}
|
||||||
displayValue={displayValue}
|
displayValue={displayValue}
|
||||||
rawValue={rawValue}
|
rawValue={rawValue}
|
||||||
isModified={cellData?.isModified || false}
|
isModified={cellData?.isModified || false}
|
||||||
nextContentCol={cellData?.nextContentCol || 0}
|
|
||||||
availablePx={cellData?.availablePx || widths[columnIndex]}
|
availablePx={cellData?.availablePx || widths[columnIndex]}
|
||||||
onCellClick={handleCellClick}
|
onCellClick={handleCellClick}
|
||||||
onCellDoubleClick={handleCellDoubleClick}
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
@@ -165,7 +112,7 @@ export const CellRenderer = ({
|
|||||||
setActiveSheet={setActiveSheet}
|
setActiveSheet={setActiveSheet}
|
||||||
activeCellRef={activeCellInputRef}
|
activeCellRef={activeCellInputRef}
|
||||||
mergedCellInfo={mergedCellProps}
|
mergedCellInfo={mergedCellProps}
|
||||||
highlightColor={highlightColor}
|
highlightColor={highlightedCell?.color}
|
||||||
isCopied={isCopied}
|
isCopied={isCopied}
|
||||||
isCut={isCut}
|
isCut={isCut}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -185,6 +185,16 @@ export const FormulaBar = memo(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ExcelUploadPanel */}
|
||||||
|
{onExcelFileChange && (
|
||||||
|
<ExcelUploadPanel
|
||||||
|
fileName={excelFileName}
|
||||||
|
isLoading={isExcelLoading}
|
||||||
|
onUploadClick={() => {}}
|
||||||
|
onFileChange={onExcelFileChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Кнопка сохранения */}
|
{/* Кнопка сохранения */}
|
||||||
{onManualSave && (
|
{onManualSave && (
|
||||||
<button
|
<button
|
||||||
@@ -215,16 +225,6 @@ export const FormulaBar = memo(
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ExcelUploadPanel */}
|
|
||||||
{onExcelFileChange && (
|
|
||||||
<ExcelUploadPanel
|
|
||||||
fileName={excelFileName}
|
|
||||||
isLoading={isExcelLoading}
|
|
||||||
onUploadClick={() => {}}
|
|
||||||
onFileChange={onExcelFileChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
|
|||||||
import { getColumnLabel } from '../utils'
|
import { getColumnLabel } from '../utils'
|
||||||
import { CellRenderer } from './CellRenderer'
|
import { CellRenderer } from './CellRenderer'
|
||||||
|
|
||||||
// Создаем типизированный компонент для совместимости с React 18
|
|
||||||
const Grid = VariableSizeGrid as unknown as React.ComponentType<any>
|
const Grid = VariableSizeGrid as unknown as React.ComponentType<any>
|
||||||
|
|
||||||
// --- Spreadsheet Component ---
|
|
||||||
export const Spreadsheet = ({
|
export const Spreadsheet = ({
|
||||||
sheetType,
|
sheetType,
|
||||||
columnWidths,
|
columnWidths,
|
||||||
@@ -40,12 +38,12 @@ export const Spreadsheet = ({
|
|||||||
mergedCells,
|
mergedCells,
|
||||||
copiedCells,
|
copiedCells,
|
||||||
}: SpreadsheetProps) => {
|
}: SpreadsheetProps) => {
|
||||||
const widths = columnWidths
|
|
||||||
const cachedData = getCachedCellData(sheetType)
|
const cachedData = getCachedCellData(sheetType)
|
||||||
|
const isActiveSheet = activeSheet === sheetType
|
||||||
|
|
||||||
const itemData = {
|
const itemData = {
|
||||||
sheetType,
|
sheetType,
|
||||||
widths,
|
widths: columnWidths,
|
||||||
selectedCell,
|
selectedCell,
|
||||||
multiSelection,
|
multiSelection,
|
||||||
activeSheet,
|
activeSheet,
|
||||||
@@ -55,8 +53,6 @@ export const Spreadsheet = ({
|
|||||||
mergedCells,
|
mergedCells,
|
||||||
highlightedCells,
|
highlightedCells,
|
||||||
copiedCells,
|
copiedCells,
|
||||||
multiSelectionActive:
|
|
||||||
activeSheet === sheetType && multiSelection.length > 0,
|
|
||||||
handleCellClick,
|
handleCellClick,
|
||||||
handleCellDoubleClick,
|
handleCellDoubleClick,
|
||||||
handleCellChange,
|
handleCellChange,
|
||||||
@@ -70,19 +66,8 @@ export const Spreadsheet = ({
|
|||||||
activeCellInputRef,
|
activeCellInputRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отладочное логирование для проверки состояния
|
|
||||||
// console.log(`📊 Spreadsheet ${sheetType} состояние:`, {
|
|
||||||
// isEditing,
|
|
||||||
// isInEditMode,
|
|
||||||
// selectedCell,
|
|
||||||
// activeSheet,
|
|
||||||
// isActiveSheet: activeSheet === sheetType,
|
|
||||||
//})
|
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
|
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
// Состояние текущего скролла грида
|
|
||||||
const [scrollPos, setScrollPos] = useState({ left: 0, top: 0 })
|
const [scrollPos, setScrollPos] = useState({ left: 0, top: 0 })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,17 +85,98 @@ export const Spreadsheet = ({
|
|||||||
return () => resizeObserver.disconnect()
|
return () => resizeObserver.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectedRows = new Set<number>()
|
// Вычисляем выделенные строки и колонки для активного листа
|
||||||
const selectedCols = new Set<number>()
|
const getSelectedRowsAndCols = () => {
|
||||||
if (sheetType === activeSheet && multiSelection.length > 0) {
|
const selectedRows = new Set<number>()
|
||||||
multiSelection.forEach(sel => {
|
const selectedCols = new Set<number>()
|
||||||
const top = Math.min(sel.startRow, sel.endRow)
|
|
||||||
const bottom = Math.max(sel.startRow, sel.endRow)
|
if (isActiveSheet && multiSelection.length > 0) {
|
||||||
const left = Math.min(sel.startCol, sel.endCol)
|
multiSelection.forEach(sel => {
|
||||||
const right = Math.max(sel.startCol, sel.endCol)
|
const top = Math.min(sel.startRow, sel.endRow)
|
||||||
for (let r = top; r <= bottom; r++) selectedRows.add(r)
|
const bottom = Math.max(sel.startRow, sel.endRow)
|
||||||
for (let c = left; c <= right; c++) selectedCols.add(c)
|
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 (
|
return (
|
||||||
@@ -131,12 +197,10 @@ export const Spreadsheet = ({
|
|||||||
style={{ userSelect: isEditing ? 'text' : 'none' }}
|
style={{ userSelect: isEditing ? 'text' : 'none' }}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{/* Spreadsheet Body */}
|
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1">
|
||||||
{/* Row Headers */}
|
{/* Row Headers */}
|
||||||
<div className="flex-shrink-0 bg-muted">
|
<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="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">
|
<div className="absolute inset-0 flex items-end justify-end p-0.5">
|
||||||
<span
|
<span
|
||||||
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
|
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>
|
<div>
|
||||||
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => {
|
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => {
|
||||||
const isActiveRow =
|
const isActiveRow =
|
||||||
(selectedCell?.row === rowIndex &&
|
(selectedCell?.row === rowIndex && isActiveSheet) ||
|
||||||
activeSheet === sheetType) ||
|
|
||||||
selectedRows.has(rowIndex)
|
selectedRows.has(rowIndex)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={rowIndex}
|
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}
|
{rowIndex + 1}
|
||||||
</div>
|
</div>
|
||||||
@@ -184,38 +251,25 @@ export const Spreadsheet = ({
|
|||||||
className="column-headers overflow-x-hidden"
|
className="column-headers overflow-x-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{Array.from({ length: COL_COUNT }).map((_, i) => {
|
{Array.from({ length: COL_COUNT }).map((_, colIndex) => {
|
||||||
const isActiveColumn =
|
const isActiveColumn =
|
||||||
(selectedCell?.col === i && activeSheet === sheetType) ||
|
(selectedCell?.col === colIndex && isActiveSheet) ||
|
||||||
selectedCols.has(i)
|
selectedCols.has(colIndex)
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const startX = e.clientX
|
|
||||||
const startWidth = widths[i]
|
|
||||||
|
|
||||||
const handleMouseMove = (me: MouseEvent) => {
|
|
||||||
const newWidth = startWidth + (me.clientX - startX)
|
|
||||||
handleColumnResize(sheetType, i, newWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={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'}`}
|
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 ${
|
||||||
style={{ width: widths[i] }}
|
isActiveColumn
|
||||||
|
? 'bg-primary/15 font-medium text-foreground'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
style={{ width: columnWidths[colIndex] }}
|
||||||
>
|
>
|
||||||
{getColumnLabel(i)}
|
{getColumnLabel(colIndex)}
|
||||||
<div
|
<div
|
||||||
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -225,60 +279,7 @@ export const Spreadsheet = ({
|
|||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
{/* selection overlay */}
|
{renderSelectionOverlay()}
|
||||||
{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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
<Grid
|
<Grid
|
||||||
ref={(el: any) => {
|
ref={(el: any) => {
|
||||||
if (gridRefs.current) {
|
if (gridRefs.current) {
|
||||||
@@ -287,7 +288,7 @@ export const Spreadsheet = ({
|
|||||||
}}
|
}}
|
||||||
columnCount={COL_COUNT}
|
columnCount={COL_COUNT}
|
||||||
rowCount={ROW_COUNT}
|
rowCount={ROW_COUNT}
|
||||||
columnWidth={(index: number) => widths[index]}
|
columnWidth={(index: number) => columnWidths[index]}
|
||||||
rowHeight={() => 28}
|
rowHeight={() => 28}
|
||||||
height={gridSize.height}
|
height={gridSize.height}
|
||||||
width={gridSize.width}
|
width={gridSize.width}
|
||||||
|
|||||||
@@ -64,47 +64,40 @@ export interface CellProps {
|
|||||||
rowIndex: number
|
rowIndex: number
|
||||||
colIndex: number
|
colIndex: number
|
||||||
columnWidth: number
|
columnWidth: number
|
||||||
columnWidths: number[] // Добавляем массив всех ширин колонок
|
columnWidths: number[]
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
isInMultiSelection: boolean // Является ли ячейка частью множественного выделения
|
isInMultiSelection: boolean
|
||||||
multiSelectionActive: boolean // Есть ли вообще активное множественное выделение
|
multiSelectionActive: boolean
|
||||||
// Флаги, является ли ячейка на краю выделения
|
|
||||||
selectionTop?: boolean
|
|
||||||
selectionBottom?: boolean
|
|
||||||
selectionLeft?: boolean
|
|
||||||
selectionRight?: boolean
|
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
displayValue: string // Отображаемое значение (вычисленное)
|
displayValue: string
|
||||||
rawValue: string // "Сырое" значение (формула)
|
rawValue: string
|
||||||
isModified: boolean
|
isModified: boolean
|
||||||
nextContentCol: number // индекс ближайшей занятой колонки
|
availablePx: number
|
||||||
availablePx: number // суммарная ширина, куда можно расширяться
|
|
||||||
onCellClick: (row: number, col: number, event?: React.MouseEvent) => void
|
onCellClick: (row: number, col: number, event?: React.MouseEvent) => void
|
||||||
onCellDoubleClick: (row: number, col: number) => void
|
onCellDoubleClick: (row: number, col: number) => void
|
||||||
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
|
onCellValueChange: (newValue: string) => void
|
||||||
onCellReferenceClick?: (
|
onCellReferenceClick?: (
|
||||||
sheetType: SheetType,
|
sheetType: SheetType,
|
||||||
row: number,
|
row: number,
|
||||||
col: number
|
col: number
|
||||||
) => void // Клик для вставки ссылки
|
) => void
|
||||||
onCellMouseDown?: (row: number, col: number, event: React.MouseEvent) => void // Для начала drag selection
|
onCellMouseDown?: (row: number, col: number, event: React.MouseEvent) => void
|
||||||
onCellMouseEnter?: (row: number, col: number) => void // Для drag selection
|
onCellMouseEnter?: (row: number, col: number) => void
|
||||||
onCellMouseUp?: () => void // Для завершения drag selection
|
onCellMouseUp?: () => void
|
||||||
isInEditMode: boolean // Глобальный режим редактирования
|
isInEditMode: boolean
|
||||||
stopEditing: (save: boolean) => void
|
stopEditing: (save: boolean) => void
|
||||||
setActiveSheet: (type: SheetType) => void
|
setActiveSheet: (type: SheetType) => void
|
||||||
activeCellRef: React.RefObject<HTMLInputElement | null>
|
activeCellRef: React.RefObject<HTMLInputElement | null>
|
||||||
style: CSSProperties // Добавляем style для react-window
|
style: CSSProperties
|
||||||
mergedCellInfo?: {
|
mergedCellInfo?: {
|
||||||
// Информация об объединенных ячейках
|
|
||||||
isMerged: boolean
|
isMerged: boolean
|
||||||
isMainCell: boolean
|
isMainCell: boolean
|
||||||
rowSpan?: number
|
rowSpan?: number
|
||||||
colSpan?: number
|
colSpan?: number
|
||||||
}
|
}
|
||||||
isCopied?: boolean // Является ли ячейка скопированной
|
isCopied?: boolean
|
||||||
isCut?: boolean // Является ли ячейка вырезанной
|
isCut?: boolean
|
||||||
highlightColor?: string // Цвет подсветки для формул
|
highlightColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormulaBarProps {
|
export interface FormulaBarProps {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useSafeNavigation } from '@/hook/useSafeNavigation'
|
||||||
import { Template } from '@/type/template'
|
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 React from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Button } from 'shared/ui/button'
|
import { Button } from 'shared/ui/button'
|
||||||
@@ -7,17 +8,42 @@ import { Button } from 'shared/ui/button'
|
|||||||
interface HeaderBarProps {
|
interface HeaderBarProps {
|
||||||
template: Template
|
template: Template
|
||||||
onBack: () => void
|
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 navigate = useNavigate()
|
||||||
|
|
||||||
|
// Хук для безопасной навигации
|
||||||
|
const { safeNavigate, isNavigating } = useSafeNavigation({
|
||||||
|
onBeforeNavigate,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSaving,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 border-b bg-card px-4 py-2">
|
<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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button
|
||||||
<ArrowLeft className="h-4 w-4" />
|
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>
|
</Button>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
@@ -32,20 +58,40 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
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"
|
className="flex items-center gap-1 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4" />
|
{isNavigating ? (
|
||||||
<span className="text-sm">Редактор интерфейса</span>
|
<>
|
||||||
|
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
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"
|
className="flex items-center gap-1 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
{isNavigating ? (
|
||||||
<span className="text-sm">Создать протокол</span>
|
<>
|
||||||
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
|
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
|
||||||
import { ExcelParseResult } from '@/service/fileApiService'
|
import React, { useCallback, useRef } from 'react'
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
interface SpreadsheetViewerProps {
|
interface SpreadsheetViewerProps {
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
mergedCells: ExcelParseResult['mergedCells']
|
mergedCells: any[]
|
||||||
dataVersion: number
|
dataVersion: number
|
||||||
templateId: string | undefined
|
templateId: string
|
||||||
// Пропсы для ExcelUploadPanel
|
|
||||||
excelFileName?: string
|
excelFileName?: string
|
||||||
isExcelLoading?: boolean
|
isExcelLoading: boolean
|
||||||
onExcelFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
onExcelFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onAutoSaveStateChange?: (state: {
|
||||||
|
hasUnsavedChanges: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
manualSave: () => Promise<void>
|
||||||
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
||||||
@@ -21,16 +24,37 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
|||||||
excelFileName,
|
excelFileName,
|
||||||
isExcelLoading,
|
isExcelLoading,
|
||||||
onExcelFileChange,
|
onExcelFileChange,
|
||||||
}) => (
|
onAutoSaveStateChange,
|
||||||
<div className="min-h-0 flex-1">
|
}) => {
|
||||||
<DualSpreadsheet
|
// Используем ref для стабильного коллбека
|
||||||
key={dataVersion}
|
const onAutoSaveStateChangeRef = useRef(onAutoSaveStateChange)
|
||||||
templateData={{ L: data }}
|
onAutoSaveStateChangeRef.current = onAutoSaveStateChange
|
||||||
mergedCells={mergedCells}
|
|
||||||
templateId={templateId}
|
const handleEngineReady = useCallback((engine: any) => {
|
||||||
excelFileName={excelFileName}
|
if (!engine || !onAutoSaveStateChangeRef.current) return
|
||||||
isExcelLoading={isExcelLoading}
|
|
||||||
onExcelFileChange={onExcelFileChange}
|
// Передаем состояние родителю только один раз при инициализации движка
|
||||||
/>
|
const state = {
|
||||||
</div>
|
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,
|
parseExcelFile,
|
||||||
} from '@/service/fileApiService'
|
} from '@/service/fileApiService'
|
||||||
import { Template } from '@/type/template'
|
import { Template } from '@/type/template'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import { HeaderBar } from './HeaderBar'
|
import { HeaderBar } from './HeaderBar'
|
||||||
import { SpreadsheetViewer } from './SpreadsheetViewer'
|
import { SpreadsheetViewer } from './SpreadsheetViewer'
|
||||||
|
|
||||||
@@ -26,6 +26,17 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
|||||||
const [dataVersion, setDataVersion] = useState(0)
|
const [dataVersion, setDataVersion] = useState(0)
|
||||||
const [serverFileName, setServerFileName] = useState<string | undefined>()
|
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()
|
const { updateTemplate } = useTemplateContext()
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background">
|
<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
|
<SpreadsheetViewer
|
||||||
data={excelData}
|
data={excelData}
|
||||||
mergedCells={editedTemplate.mergedCells || []}
|
mergedCells={editedTemplate.mergedCells || []}
|
||||||
@@ -119,6 +147,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
|||||||
excelFileName={serverFileName || editedTemplate.excelFile?.name}
|
excelFileName={serverFileName || editedTemplate.excelFile?.name}
|
||||||
isExcelLoading={isLoading}
|
isExcelLoading={isLoading}
|
||||||
onExcelFileChange={handleFileChange}
|
onExcelFileChange={handleFileChange}
|
||||||
|
onAutoSaveStateChange={handleAutoSaveStateChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { TemplateEditor } from '@/component/TemplateManager/TemplateEditor'
|
||||||
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
||||||
|
import { useSafeNavigation } from '@/hook/useSafeNavigation'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
export const TemplateEditPage = () => {
|
export const TemplateEditPage = () => {
|
||||||
@@ -7,11 +8,16 @@ export const TemplateEditPage = () => {
|
|||||||
const { templates } = useTemplateContext()
|
const { templates } = useTemplateContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { safeNavigate } = useSafeNavigation({
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
isSaving: false,
|
||||||
|
})
|
||||||
|
|
||||||
const template = templates.find(t => t.id === templateId)
|
const template = templates.find(t => t.id === templateId)
|
||||||
if (!template) {
|
if (!template) {
|
||||||
navigate('/templates', { replace: true })
|
navigate('/templates', { replace: true })
|
||||||
return null
|
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 { ElementConstructor } from '@/component/TemplateManager/ElementConstructor'
|
||||||
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
||||||
import { useDelayedSave } from '@/hook/useDelayedSave'
|
import { useDelayedSave } from '@/hook/useDelayedSave'
|
||||||
|
import { useSafeNavigation } from '@/hook/useSafeNavigation'
|
||||||
import { FormLayoutSettings, Template, TemplateElement } from '@/type/template'
|
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 { FC, useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Button } from 'shared/ui/button'
|
import { Button } from 'shared/ui/button'
|
||||||
@@ -51,6 +52,13 @@ export const ElementsCreation: FC = () => {
|
|||||||
delay: 2000, // 2 секунды задержки
|
delay: 2000, // 2 секунды задержки
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Хук для безопасной навигации
|
||||||
|
const { safeNavigate, isNavigating } = useSafeNavigation({
|
||||||
|
onBeforeNavigate: manualSave,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSaving,
|
||||||
|
})
|
||||||
|
|
||||||
const handleElementAdd = async (element: TemplateElement) => {
|
const handleElementAdd = async (element: TemplateElement) => {
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
return
|
return
|
||||||
@@ -172,9 +180,14 @@ export const ElementsCreation: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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>
|
</Button>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Wrench className="h-5 w-5" />
|
<Wrench className="h-5 w-5" />
|
||||||
@@ -189,22 +202,44 @@ export const ElementsCreation: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
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"
|
className="flex items-center gap-1 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
{isNavigating ? (
|
||||||
<span className="text-sm">Настройки шаблона</span>
|
<>
|
||||||
|
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/templates/${selectedTemplate.id}/protocols`)
|
safeNavigate(`/templates/${selectedTemplate.id}/protocols`)
|
||||||
}
|
}
|
||||||
|
disabled={isNavigating || isSaving}
|
||||||
className="flex items-center gap-1 hover:bg-muted"
|
className="flex items-center gap-1 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
{isNavigating ? (
|
||||||
<span className="text-sm">Создать протокол</span>
|
<>
|
||||||
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
|
import DualSpreadsheet from '@/component/DualSpreadsheet/DualSpreadsheet'
|
||||||
import { getElementDefinition } from '@/entity/element/model/interface'
|
import { getElementDefinition } from '@/entity/element/model/interface'
|
||||||
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entity/template/model/TemplateContext'
|
||||||
|
import { useSafeNavigation } from '@/hook/useSafeNavigation'
|
||||||
import { cellAddressToCoordinates } from '@/lib/cell-utils'
|
import { cellAddressToCoordinates } from '@/lib/cell-utils'
|
||||||
import { useToast } from '@/lib/hooks/useToast'
|
import { useToast } from '@/lib/hooks/useToast'
|
||||||
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
import { getLatestFileForTemplate } from '@/service/fileApiService'
|
||||||
import { Template, TemplateElement } from '@/type/template'
|
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 { FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Button } from 'shared/ui/button'
|
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 [formData, setFormData] = useState<Record<string, any>>({})
|
||||||
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
const [isSavingProtocol, setIsSavingProtocol] = useState(false)
|
||||||
const engineRef = useRef<any>(null)
|
const engineRef = useRef<any>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Хук для безопасной навигации (здесь нет автосохранения формы, только состояние)
|
||||||
|
const { safeNavigate, isNavigating } = useSafeNavigation({
|
||||||
|
hasUnsavedChanges: false, // Форма протокола не требует автосохранения
|
||||||
|
isSaving: isSavingProtocol,
|
||||||
|
})
|
||||||
|
|
||||||
// Инициализация начальных значений формы (только один раз)
|
// Инициализация начальных значений формы (только один раз)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized && template.elements.length > 0) {
|
if (!isInitialized && template.elements.length > 0) {
|
||||||
@@ -108,6 +123,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
onSave(data)
|
onSave(data)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsSavingProtocol(true)
|
||||||
|
|
||||||
if (!engineRef.current) {
|
if (!engineRef.current) {
|
||||||
toast.error('Движок ещё не инициализирован')
|
toast.error('Движок ещё не инициализирован')
|
||||||
return
|
return
|
||||||
@@ -195,6 +212,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ошибка при создании протокола', err)
|
console.error('Ошибка при создании протокола', err)
|
||||||
toast.error('Ошибка при создании протокола')
|
toast.error('Ошибка при создании протокола')
|
||||||
|
} finally {
|
||||||
|
setIsSavingProtocol(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,8 +260,17 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
{/* Левая часть: кнопка назад и заголовок */}
|
{/* Левая часть: кнопка назад и заголовок */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button
|
||||||
<ArrowLeft className="h-4 w-4" />
|
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>
|
</Button>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileText className="h-5 w-5" />
|
<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">
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={showSpreadsheet ? 'default' : 'outline'}
|
variant="outline"
|
||||||
size="icon"
|
onClick={handleSave}
|
||||||
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
|
disabled={!canSave || isSavingProtocol}
|
||||||
aria-label={
|
|
||||||
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Grid className="h-4 w-4" />
|
{isSavingProtocol ? (
|
||||||
</Button>
|
<>
|
||||||
<Button variant="outline" onClick={handleSave} disabled={!canSave}>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Save className="h-4 w-4" />
|
Сохранение...
|
||||||
Сохранить протокол
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Сохранить протокол
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Кнопки справа */}
|
{/* Кнопки справа */}
|
||||||
@@ -275,20 +306,40 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
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"
|
className="flex items-center gap-1 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
{isNavigating ? (
|
||||||
<span className="text-sm">Редактор шаблона</span>
|
<>
|
||||||
|
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
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"
|
className="flex items-center gap-1 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4" />
|
{isNavigating ? (
|
||||||
<span className="text-sm">Редактор интерфейса</span>
|
<>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: ['class'],
|
darkMode: ['class'],
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{js,ts,jsx,tsx}',
|
||||||
|
'./shared/**/*.{js,ts,jsx,tsx}',
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
Reference in New Issue
Block a user