import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import { nonNegativeColumnKeys } from '../../constants/validation'; import { useRealtimeActions } from '../../contexts/RealtimeContext'; import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, normalizeNumericInput, resolveEditorState, } from '../../utils/formulaUtils'; import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils'; // Вычисляет отображаемое значение формулы, не бросая исключение наружу. const computeFormulaDisplay = (formulaValue) => { try { return formatNumber(calculateFormula(extractFormula(formulaValue))); } catch { return '#ОШИБКА'; } }; const EDITOR_STYLES = { position: 'absolute', zIndex: 12, top: 0, }; const FORMULA_INDICATOR_STYLES = { position: 'absolute', top: '-20px', left: '4px', fontSize: '10px', color: '#666', background: '#f0f0f0', padding: '2px 6px', borderRadius: '3px', pointerEvents: 'none', }; const TEXTAREA_STYLES = { width: '100%', minHeight: '3rem', height: 'auto', fieldSizing: 'content', fontSize: 'inherit', }; const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => { const { endEditing: contextEndEditing } = useRealtimeActions(); const storageKey = `formula_v2_${tableId}_${cellId}`; // AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не // редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и // подставляют этот символ (или пустую строку) вместо текущего значения — // RealtimeTable хранит его в своём state и отдаёт через // table.options.meta.getEditorInitialInput(), тот же приём, что уже // применяется для getSelectedColumnId/getColumnTitle в этом файле. // Инициализатор useState вызывается один раз при монтировании — ровно // тогда, когда этот редактор открылся именно так. // hasInitialInputRef: редактор открыт набором символа на выделенной ячейке — // пользователь заменяет значение с нуля, сохранённую формулу подставлять нельзя. const hasInitialInputRef = useRef(false); const [state, setState] = useState(() => { const initialInput = table?.options?.meta?.getEditorInitialInput?.(); if (initialInput !== null && initialInput !== undefined) { hasInitialInputRef.current = true; return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false }; } return { currentValue: value, displayValue: value, isFormulaMode: false }; }); // Исходное значение редактора на момент открытия — чтобы не отправлять // на сервер открытие и закрытие ячейки без правки (AURORA-1895). const initialEditorValueRef = useRef(value); // Актуальное значение ячейки для эффекта разрешения формулы: эффект // срабатывает один раз при открытии и не должен затирать набранный текст, // если другой пользователь изменит ячейку, пока она открыта. const valueRef = useRef(value); valueRef.current = value; // Позиция ячейки const [position, setPosition] = useState(); const textareaRef = useRef(null); // AURORA-1891: этой ячейки — чтобы после закрытия редактора вернуть // туда фокус (иначе стрелки/клавиатурная навигация отваливаются после // первого же редактирования, см. muiTableBodyCellProps.onKeyDown). const tdRef = useRef(null); const refFinished = useRef(false); // Загрузка сохраненной формулы (AURORA-1895): подставляем её только если // результат совпадает с текущим значением ячейки, иначе формула устарела // (stale: значение изменил другой пользователь) и удаляется из хранилища. useEffect(() => { if (hasInitialInputRef.current) return; const currentCellValue = valueRef.current; const savedFormula = loadFromStorage(storageKey); const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, currentCellValue); if (stale) { removeFromStorage(storageKey); } initialEditorValueRef.current = currentValue; if (isFormulaMode) { setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true }); } }, [storageKey]); // Вычисление позиции useLayoutEffect(() => { if (!refCell?.current) return; const td = refCell.current.offsetParent; const tr = td?.offsetParent; if (td && tr) { tdRef.current = td; setPosition({ left: td.offsetLeft, width: td.offsetWidth, translateY: tr.style.transform || '', }); } }, [refCell]); // AURORA-1891: при закрытии редактора (размонтирование) фокус возвращается // в этой ячейки — без этого фокус терялся бы на удалённом textarea, // и стрелки/Tab/Enter перестают работать сразу после первого редактирования. useEffect(() => { return () => { tdRef.current?.focus?.(); }; }, []); // Auto-resize useLayoutEffect(() => { const el = textareaRef.current; if (!el) return; const resize = () => { el.style.minHeight = '3rem'; el.style.height = '3rem'; el.style.height = `${el.scrollHeight}px`; el.style.maxHeight = '180px'; }; resize(); const observer = new ResizeObserver(resize); observer.observe(el); return () => observer.disconnect(); }, [state.currentValue]); useEffect(() => { // AURORA-1891: setTimeout(50) был произвольной задержкой "на всякий // случай"; requestAnimationFrame ждёт ровно до следующего кадра // отрисовки — DOM (textarea) к этому моменту уже готов, фокус приходит // на кадр раньше и без угадывания длительности. const frameId = requestAnimationFrame(() => { if (textareaRef.current && !disabled) { textareaRef.current.focus(); textareaRef.current.select(); } }); return () => cancelAnimationFrame(frameId); }, [disabled]); // AURORA-1892: мягкий запрет минуса для заданных колонок — предупреждаем, // но значение всё равно сохраняется (жёсткий запрет в этой задаче не делается). const warnIfNegativeNotAllowed = useCallback( (newValue) => { const columnId = cell?.column?.id; if (!columnId || !nonNegativeColumnKeys.includes(columnId)) return; if (!Number.isFinite(Number(newValue)) || Number(newValue) >= 0) return; const columnTitle = table?.options?.meta?.getColumnTitle?.(columnId) ?? columnId; toast.warning(`В колонке «${columnTitle}» отрицательные суммы запрещены`); }, [cell, table], ); // Возвращает true, если можно закрывать редактор (сохранено либо не было // изменений), false — если формула некорректна и редактор должен остаться // открытым (ничего не отправляем на сервер в этом случае). const handleSave = useCallback(() => { const { currentValue } = state; // AURORA-1891/1895: ячейку открыли и закрыли без правки — на сервер // ничего не шлём (ни cell_updated, ни запись в аудит). if (currentValue === initialEditorValueRef.current) return true; if (isFormula(currentValue)) { let result; try { result = calculateFormula(extractFormula(currentValue)); } catch { setState((prev) => ({ ...prev, displayValue: '#ОШИБКА', isFormulaMode: true, })); toast.error('Формула не вычисляется, проверьте выражение'); return false; } const formattedResult = formatNumber(result); saveToStorage(storageKey, currentValue); setState((prev) => ({ ...prev, displayValue: formattedResult, isFormulaMode: true, })); warnIfNegativeNotAllowed(formattedResult); onChange?.(formattedResult); return true; } removeFromStorage(storageKey); setState((prev) => ({ ...prev, displayValue: currentValue, isFormulaMode: false, })); const normalizedValue = normalizeNumericInput(currentValue); warnIfNegativeNotAllowed(normalizedValue); // Значение фактически не изменилось (напр. '150' вместо 150) — не шлём // onChange (а значит и cell_updated). if (isSameCellValue(normalizedValue, value)) return true; onChange?.(normalizedValue); return true; }, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]); // Завершает редактирование текущей ячейки (cell_edit_end) — читает // table.getState().editingCell, а не cell.row/cell.column: к этому моменту // handleSave (через onChange -> EditCellPortal.handleChange) уже мог // вызвать table.setEditingCell(null), но реальным состоянием владеет // RealtimeTable.jsx (контролируемый state.editingCell), так что // table.getState().editingCell всё ещё возвращает ячейку, которую мы // только что редактировали. const endCurrentEditing = useCallback(() => { const editingCell = table.getState().editingCell; if (editingCell) { contextEndEditing?.(editingCell.row, editingCell.column); } }, [table, contextEndEditing]); // Возвращает true, если редактор закрыт; false — если остался открытым // (формула не вычисляется) либо уже был закрыт ранее. const finishEditing = useCallback(() => { if (refFinished.current) return false; if (!disabled && !handleSave()) return false; refFinished.current = true; endCurrentEditing(); return true; }, [disabled, handleSave, endCurrentEditing]); // AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого // cell_updated), только завершение редактирования текущей ячейки. const cancelEditing = useCallback(() => { if (refFinished.current) return; refFinished.current = true; endCurrentEditing(); }, [endCurrentEditing]); useEffect(() => { const handleClickOutside = (event) => { const el = textareaRef.current; if (el && !el.contains(event.target)) { finishEditing(); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [finishEditing]); const handleChange = useCallback((e) => { const value = e.target.value; setState((prev) => ({ ...prev, currentValue: value, displayValue: value, })); }, []); const handleKeyDown = useCallback( (e) => { if (refFinished.current) return; // AURORA-1891: отмена — значение не сохраняется, cell_updated не шлётся. if (e.key === 'Escape') { e.preventDefault(); cancelEditing(); return; } // AURORA-1891: Tab/Shift+Tab — сохранить и перейти к следующей/предыдущей // редактируемой ячейке в строке. Переход резолвит и прокручивает // table.options.meta.openNextEditableCell (RealtimeTable.jsx); сама // ячейка открывается позже, в эффекте, который ждёт cell_edit_end // текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем. if (e.key === 'Tab') { e.preventDefault(); // AURORA-1895: цель перехода помечаем только если редактор реально // закрылся — при невычисляемой формуле он остаётся открытым. if (finishEditing()) { table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right'); } return; } // Shift+Enter — перенос строки в textarea (стандартное поведение, // e.preventDefault() не вызывается). if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (finishEditing()) { table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down'); } } }, [cancelEditing, finishEditing, table, cell], ); const editorStyles = useMemo( () => ({ ...EDITOR_STYLES, left: position?.left || 0, width: position?.width || 0, transform: position?.translateY || 0, }), [position], ); const textareaStyles = useMemo( () => ({ ...TEXTAREA_STYLES, backgroundColor: state.isFormulaMode ? '#f8f9fa' : 'white', border: state.isFormulaMode ? '2px solid #4a90d9' : undefined, }), [state.isFormulaMode], ); return ( {position && (
{state.isFormulaMode &&
fx
}