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 }
)}
|
);
});
EditCell.displayName = 'EditCell';
export default EditCell;