Andrey Epifancev 455393cc76 Merge origin/test в feat/264-aurora-1895-formula-localstorage
Конфликты (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>
2026-11-08 10:24:55 +04:00

375 lines
14 KiB
JavaScript
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 { 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;