Конфликты (EditCell.jsx, RealtimeTable.jsx, formulaUtils.test.js): - EditCell: сохранены правки 1891 (editorInitialInput, Esc/Tab/Enter, возврат фокуса) и 1892 (warnIfNegativeNotAllowed); поверх них повторена логика 1895: формула из localStorage подставляется только если совпадает с текущим значением, иначе stale и удаляется. - Сохранённая формула не подставляется, если редактор открыт набором символа (getEditorInitialInput) и не затирает набранный текст при изменении value другим пользователем (разрешение один раз при открытии). - Tab/Enter помечают следующую ячейку (openNextEditableCell) только после фактического закрытия редактора: при невычисляемой формуле он остаётся открытым и не оставляет висящую цель перехода. - formulaUtils.js: единственный isSameCellValue из test (с нормализацией разделителей); дубль из ветки удалён. - formulaUtils.test.js: тесты обеих сторон. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
14 KiB
JavaScript
375 lines
14 KiB
JavaScript
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: <td> этой ячейки — чтобы после закрытия редактора вернуть
|
||
// туда фокус (иначе стрелки/клавиатурная навигация отваливаются после
|
||
// первого же редактирования, см. 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: при закрытии редактора (размонтирование) фокус возвращается
|
||
// в <td> этой ячейки — без этого фокус терялся бы на удалённом 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 (
|
||
<tr>
|
||
<td>
|
||
{position && (
|
||
<div style={editorStyles}>
|
||
{state.isFormulaMode && <div style={FORMULA_INDICATOR_STYLES}>fx</div>}
|
||
<textarea
|
||
ref={textareaRef}
|
||
disabled={disabled}
|
||
style={textareaStyles}
|
||
onChange={handleChange}
|
||
value={state.currentValue}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={state.isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
|
||
/>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
});
|
||
|
||
EditCell.displayName = 'EditCell';
|
||
|
||
export default EditCell;
|