Files
protoc-frontend/src/components/DualSpreadsheet/DualSpreadsheet.tsx
2025-07-15 16:41:46 +03:00

700 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { produce } from 'immer'
import {
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'
const ROW_COUNT = 90
const COL_COUNT = 20
const ROW_HEIGHT = 28
const SHEET_NAMES = {
report: 'L',
calculations: 'R',
} as const
type SheetType = keyof typeof SHEET_NAMES
// --- Helper Functions ---
const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
// --- Interfaces ---
interface DualSpreadsheetProps {
templateData?: Record<string, Record<string, any>>
}
// --- Child Components ---
interface CellProps {
sheetType: SheetType
rowIndex: number
colIndex: number
columnWidth: number
isSelected: boolean
isEditing: boolean
displayValue: string // Отображаемое значение (вычисленное)
rawValue: string // "Сырое" значение (формула)
isModified: boolean
onCellClick: (row: number, col: number) => void
onCellDoubleClick: (row: number, col: number) => void
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
stopEditing: (save: boolean) => void
setActiveSheet: (type: SheetType) => void
activeCellRef: React.RefObject<HTMLInputElement>
}
const Cell = memo(
({
sheetType,
rowIndex,
colIndex,
columnWidth,
isSelected,
isEditing,
displayValue,
rawValue,
isModified,
onCellClick,
onCellDoubleClick,
onCellValueChange,
stopEditing,
setActiveSheet,
activeCellRef,
}: CellProps) => {
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${
isModified ? 'bg-yellow-50' : ''
}`
const style = {
minWidth: `${columnWidth}px`,
width: `${columnWidth}px`,
height: `${ROW_HEIGHT}px`,
overflow: 'hidden',
padding: 0,
position: 'relative' as const,
}
const handleClick = useCallback(() => {
setActiveSheet(sheetType)
onCellClick(rowIndex, colIndex)
}, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex])
const handleDoubleClick = useCallback(
() => onCellDoubleClick(rowIndex, colIndex),
[onCellDoubleClick, rowIndex, colIndex],
)
return (
<td
className={className}
style={style}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{isEditing && isSelected ? (
<input
ref={activeCellRef}
type="text"
value={rawValue} // Во время редактирования показываем "сырое" значение
onChange={(e) => onCellValueChange(e.target.value)}
onBlur={() => 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 w-full border-2 border-primary px-2 py-1"
/>
) : (
<div
className="relative h-full w-full truncate px-2 py-1"
style={
isSelected
? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' }
: {}
}
>
{displayValue}
</div>
)}
</td>
)
},
)
interface FormulaBarProps {
sheetType: SheetType
activeSheet: SheetType
selectedCell: { row: number; col: number } | null
formulaBarValue: string
isCalculating: boolean
isEditing: boolean
onValueChange: (value: string) => void
onFocus: () => void
onBlur: () => void
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
formulaBarRef: (el: HTMLInputElement | null) => void
}
const FormulaBar = memo(
({
sheetType,
activeSheet,
selectedCell,
formulaBarValue,
isCalculating,
isEditing,
onValueChange,
onFocus,
onBlur,
onKeyDown,
formulaBarRef,
}: FormulaBarProps) => {
if (activeSheet !== sheetType) {
return (
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex h-[28px] items-center" />
</div>
)
}
return (
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex items-center space-x-2">
<div className="flex min-w-[50px] items-center justify-center rounded-md border bg-muted px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">
{selectedCell
? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}`
: 'A1'}
</span>
</div>
<div className="flex flex-1 items-center space-x-2">
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
<input
ref={formulaBarRef}
type="text"
className={`flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
isEditing ? 'ring-2 ring-ring ring-offset-2' : ''
} ${isCalculating ? 'animate-pulse' : ''}`}
placeholder="Введите формулу или значение..."
value={formulaBarValue}
onChange={(e) => onValueChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
{isCalculating && (
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
)}
</div>
</div>
</div>
)
},
)
// --- Main Component ---
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
const { revision, ...multiSheetEngine } = useMultiSheetEngine({
sheets: [
{ name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT },
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
],
initialData: templateData || {},
})
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
const [selectedCell, setSelectedCell] = useState<{
row: number
col: number
} | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
() => ({
report: Array(COL_COUNT).fill(96),
calculations: Array(COL_COUNT).fill(96),
}),
)
const containerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const activeCellInputRef = useRef<HTMLInputElement>(null)
const formulaBarRefs = useRef<Record<SheetType, HTMLInputElement | null>>({
report: null,
calculations: null,
})
const initialTemplateValues = useRef<Record<string, string>>({})
// Загрузка данных шаблона один раз при инициализации
useEffect(() => {
const reportTemplate = templateData?.[SHEET_NAMES.report]
if (reportTemplate) {
Object.entries(reportTemplate).forEach(([cellName, value]) => {
initialTemplateValues.current[cellName] = String(value)
})
}
}, [templateData])
// --- Синхронизация UI ---
// Синхронизация строки формул
const formulaBarValue = useMemo(() => {
if (isEditing) {
return editingValue
}
if (selectedCell) {
const sheetName = SHEET_NAMES[activeSheet]
return multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col,
)
}
return ''
}, [
isEditing,
editingValue,
selectedCell,
activeSheet,
multiSheetEngine,
revision,
])
// Фокус на инпуте при начале редактирования
useEffect(() => {
if (isEditing) {
if (activeCellInputRef.current) {
activeCellInputRef.current.focus()
activeCellInputRef.current.select()
}
}
}, [isEditing])
// --- Функции-обработчики ---
const startEditing = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
const rawValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col,
)
setIsEditing(true)
setEditingValue(rawValue)
}, [activeSheet, selectedCell, multiSheetEngine])
const stopEditing = useCallback(
(save: boolean) => {
if (save && selectedCell) {
const sheetName = SHEET_NAMES[activeSheet]
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
selectedCell.row,
selectedCell.col,
editingValue,
)
multiSheetEngine.debouncedRecalc()
}
setIsEditing(false)
setEditingValue('') // Очищаем временное значение
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
},
[activeSheet, selectedCell, editingValue, multiSheetEngine],
)
const handleCellChange = useCallback(
(newValue: string) => {
if (isEditing) {
setEditingValue(newValue)
}
},
[isEditing],
)
const handleClearCell = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
multiSheetEngine.setCellValue(
sheetName,
selectedCell.row,
selectedCell.col,
'',
)
}, [activeSheet, selectedCell, multiSheetEngine])
const moveSelection = useCallback(
(deltaRow: number, deltaCol: number) => {
if (!selectedCell) {
// Если ничего не выбрано, выбираем A1
setSelectedCell({ row: 0, col: 0 })
return
}
if (isEditing) {
stopEditing(true)
}
const newRow = Math.max(
0,
Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow),
)
const newCol = Math.max(
0,
Math.min(COL_COUNT - 1, selectedCell.col + deltaCol),
)
setSelectedCell({ row: newRow, col: newCol })
},
[selectedCell, isEditing, stopEditing],
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isEditing) {
// Логика внутри Cell компонента
return
}
if (!selectedCell && e.key.match(/^(Arrow|Enter|F2|Delete|Backspace)$/)) {
moveSelection(0, 0)
e.preventDefault()
return
}
if (!selectedCell) return
const keyHandlers: Record<string, () => void> = {
ArrowUp: () => moveSelection(-1, 0),
ArrowDown: () => moveSelection(1, 0),
ArrowLeft: () => moveSelection(0, -1),
ArrowRight: () => moveSelection(0, 1),
Enter: () => startEditing(),
F2: () => startEditing(),
Delete: handleClearCell,
Backspace: handleClearCell,
}
if (keyHandlers[e.key]) {
e.preventDefault()
keyHandlers[e.key]()
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
setEditingValue(e.key) // Начинаем редактирование с введенного символа
setIsEditing(true)
}
},
[selectedCell, isEditing, moveSelection, startEditing, handleClearCell],
)
const handleCellClick = useCallback(
(row: number, col: number) => {
if (isEditing) {
stopEditing(true)
}
setSelectedCell({ row, col })
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
},
[isEditing, stopEditing, activeSheet],
)
const handleCellDoubleClick = useCallback(
(row: number, col: number) => {
setSelectedCell({ row, col })
startEditing()
},
[startEditing],
)
const handleColumnResize = useCallback(
(sheetType: SheetType, colIndex: number, newWidth: number) => {
setColumnWidths(
produce((draft) => {
draft[sheetType][colIndex] = Math.max(60, newWidth)
}),
)
},
[],
)
// --- Formula Bar Specific Handlers ---
const handleFormulaBarChange = useCallback(
(newValue: string) => {
if (selectedCell) {
if (!isEditing) {
setIsEditing(true)
}
setEditingValue(newValue)
}
},
[selectedCell, isEditing],
)
const handleFormulaBarFocus = useCallback(() => {
if (selectedCell && !isEditing) {
startEditing()
}
}, [selectedCell, isEditing, startEditing])
const handleFormulaBarBlur = useCallback(() => {
if (isEditing) {
stopEditing(true)
}
}, [isEditing, stopEditing])
const handleFormulaBarKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
stopEditing(true)
moveSelection(1, 0)
// Предотвращаем прокрутку страницы
setTimeout(() => {
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
}, 0)
} else if (e.key === 'Escape') {
e.preventDefault()
stopEditing(false)
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
}
},
[stopEditing, moveSelection, activeSheet],
)
// --- Render Functions ---
const renderSpreadsheet = (sheetType: SheetType) => {
const widths = columnWidths[sheetType]
const sheetName = SHEET_NAMES[sheetType]
return (
<div
ref={(el) => (containerRefs.current[sheetType] = el)}
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm"
onKeyDown={handleKeyDown}
tabIndex={0}
>
<div className="flex-shrink-0 border-b bg-muted/50 px-3 py-1">
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${
sheetType === 'report' ? 'bg-primary' : 'bg-emerald-500'
}`}
></div>
<h3 className="text-xs font-medium text-muted-foreground">
{sheetType === 'report' ? 'Отчет' : 'Расчеты'}
</h3>
</div>
</div>
<div className="flex-1 overflow-auto">
<table
className="w-full border-collapse"
style={{ tableLayout: 'fixed' }}
>
<thead>
<tr>
<th className="h-7 w-10 border border-border bg-muted text-xs font-medium text-muted-foreground"></th>
{Array.from({ length: COL_COUNT }, (_, i) => {
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 (
<th
key={i}
className="relative h-7 border border-border bg-muted text-center text-xs font-medium text-muted-foreground"
style={{ width: widths[i] }}
>
{getColumnLabel(i)}
<div
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
onMouseDown={handleMouseDown}
/>
</th>
)
})}
</tr>
</thead>
<tbody>
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
<tr key={rowIndex}>
<td className="h-7 w-10 border border-border bg-muted text-center text-xs font-medium text-muted-foreground">
{rowIndex + 1}
</td>
{Array.from({ length: COL_COUNT }).map((_, colIndex) => {
const isCellSelected =
selectedCell?.row === rowIndex &&
selectedCell?.col === colIndex &&
activeSheet === sheetType
const isCellEditing = isCellSelected && isEditing
const rawValue = isCellEditing
? editingValue
: multiSheetEngine.getCellRawValue(
sheetName,
rowIndex,
colIndex,
)
const displayValue = isCellEditing
? '' // Не показывать вычисленное значение во время редактирования
: String(
multiSheetEngine.getCellValue(
sheetName,
rowIndex,
colIndex,
) || '',
)
const cellName = getColumnLabel(colIndex) + (rowIndex + 1)
const templateValue =
initialTemplateValues.current[cellName] ?? ''
const isModified =
sheetType === 'report' &&
templateValue !== '' &&
rawValue !== templateValue
return (
<Cell
key={colIndex}
sheetType={sheetType}
rowIndex={rowIndex}
colIndex={colIndex}
columnWidth={widths[colIndex]}
isSelected={isCellSelected}
isEditing={isCellEditing}
displayValue={displayValue}
rawValue={rawValue}
isModified={isModified}
onCellClick={handleCellClick}
onCellDoubleClick={handleCellDoubleClick}
onCellValueChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellRef={activeCellInputRef}
/>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// Используем revision в качестве зависимости, чтобы гарантировать перерисовку
// при изменении данных в движке.
const memoizedReportSheet = useMemo(
() => renderSpreadsheet('report'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
columnWidths.report,
activeSheet,
selectedCell,
isEditing,
editingValue,
revision,
],
)
const memoizedCalculationsSheet = useMemo(
() => renderSpreadsheet('calculations'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
columnWidths.calculations,
activeSheet,
selectedCell,
isEditing,
editingValue,
revision,
],
)
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex-shrink-0">
<FormulaBar
sheetType={activeSheet}
activeSheet={activeSheet}
selectedCell={selectedCell}
formulaBarValue={formulaBarValue}
isCalculating={multiSheetEngine.isCalculating}
isEditing={isEditing}
onValueChange={handleFormulaBarChange}
onFocus={handleFormulaBarFocus}
onBlur={handleFormulaBarBlur}
onKeyDown={handleFormulaBarKeyDown}
formulaBarRef={(el) => (formulaBarRefs.current[activeSheet] = el)}
/>
</div>
{/* Двойная панель */}
<div className="flex flex-1 gap-1 overflow-hidden p-1">
<div className="min-w-0 flex-1">{memoizedReportSheet}</div>
<div className="min-w-0 flex-1">{memoizedCalculationsSheet}</div>
</div>
</div>
)
}
export default DualSpreadsheet