(() => {
// Создаем сетку 20x10 с пустыми ячейками
return Array(20).fill(null).map(() =>
@@ -28,14 +42,50 @@ const Spreadsheet: FC = () => {
inputRefs.current = Array(20).fill(null).map(() => Array(10).fill(null));
}, []);
+ // Синхронизируем локальное состояние с движком
+ useEffect(() => {
+ setCells(prev => {
+ const newCells = [...prev];
+ for (let row = 0; row < 20; row++) {
+ for (let col = 0; col < 10; col++) {
+ const rawValue = getCellRawValue(row, col);
+ if (rawValue !== newCells[row][col].value) {
+ newCells[row][col] = { ...newCells[row][col], value: rawValue };
+ }
+ }
+ }
+ return newCells;
+ });
+ }, [engineCells, getCellRawValue]);
+
+ // Тестовые данные для демонстрации
+ useEffect(() => {
+ // Добавляем тестовые данные
+ setEngineCellValue(0, 0, '10'); // A1 = 10
+ setEngineCellValue(1, 0, '20'); // A2 = 20
+ setEngineCellValue(2, 0, '=A1+A2'); // A3 = A1+A2
+ setEngineCellValue(0, 1, '=A1*2'); // B1 = A1*2
+ setEngineCellValue(1, 1, '=SUM(A1,A2)'); // B2 = SUM(A1,A2)
+ setEngineCellValue(0, 2, '="Результат: " + A3'); // C1 = "Результат: " + A3
+
+ // Демонстрация волатильных функций
+ setEngineCellValue(0, 3, '=RAND()'); // D1 = случайное число
+ setEngineCellValue(1, 3, '=RANDBETWEEN(1,100)'); // D2 = случайное число от 1 до 100
+ setEngineCellValue(2, 3, '=D1*100'); // D3 = D1*100 (зависит от волатильной функции)
+ setEngineCellValue(0, 4, '=TODAY()'); // E1 = сегодняшняя дата
+ setEngineCellValue(1, 4, '=NOW()'); // E2 = текущая дата и время
+ }, [setEngineCellValue]);
+
// Синхронизируем значение formula bar с выбранной ячейкой
useEffect(() => {
if (selectedCell) {
- setFormulaBarValue(cells[selectedCell.row][selectedCell.col].value);
+ // Показываем сырое значение (формулу) в formula bar
+ const rawValue = getCellRawValue(selectedCell.row, selectedCell.col);
+ setFormulaBarValue(rawValue);
} else {
setFormulaBarValue('');
}
- }, [selectedCell, cells]);
+ }, [selectedCell, getCellRawValue, engineCells]);
const handleCellClick = (row: number, col: number) => {
setSelectedCell({ row, col });
@@ -45,7 +95,7 @@ const Spreadsheet: FC = () => {
const handleCellDoubleClick = (row: number, col: number) => {
setSelectedCell({ row, col });
- // Сохраняем текущее значение для возможности отмены
+ // Сохраняем текущее значение из UI для возможности отмены
setEditingValue(cells[row][col].value);
setIsEditing(true);
// Фокус на input ячейки и курсор в конец
@@ -56,7 +106,7 @@ const Spreadsheet: FC = () => {
// Устанавливаем курсор в конец текста
input.setSelectionRange(input.value.length, input.value.length);
}
- }, 0);
+ }, 10); // Увеличиваем задержку для надежности
};
const moveSelection = (deltaRow: number, deltaCol: number) => {
@@ -71,7 +121,7 @@ const Spreadsheet: FC = () => {
const startEditing = (focusTarget?: 'cell' | 'formula') => {
if (!selectedCell) return;
- // Сохраняем текущее значение для возможности отмены
+ // Сохраняем текущее значение из UI для возможности отмены
setEditingValue(cells[selectedCell.row][selectedCell.col].value);
setIsEditing(true);
@@ -98,23 +148,37 @@ const Spreadsheet: FC = () => {
const stopEditing = (save: boolean = true) => {
if (!save && selectedCell) {
// Отменяем изменения - возвращаем старое значение
- handleCellChange(selectedCell.row, selectedCell.col, editingValue);
+ setCells(prev => {
+ const newCells = [...prev];
+ newCells[selectedCell.row][selectedCell.col] = {
+ ...newCells[selectedCell.row][selectedCell.col],
+ value: editingValue
+ };
+ return newCells;
+ });
setFormulaBarValue(editingValue);
+ // Восстанавливаем значение в движке
+ setEngineCellValue(selectedCell.row, selectedCell.col, editingValue);
}
setIsEditing(false);
containerRef.current?.focus();
};
const handleCellChange = (row: number, col: number, value: string) => {
+ // Обновляем локальное состояние для UI (сырое значение)
setCells(prev => {
const newCells = [...prev];
newCells[row][col] = { ...newCells[row][col], value };
return newCells;
});
+
// Обновляем formula bar если изменяется текущая выбранная ячейка
if (selectedCell && selectedCell.row === row && selectedCell.col === col) {
setFormulaBarValue(value);
}
+
+ // Обновляем значение в движке (это может быть медленно, поэтому делаем в конце)
+ setEngineCellValue(row, col, value);
};
const getColumnLabel = (index: number) => {
@@ -178,7 +242,7 @@ const Spreadsheet: FC = () => {
e.preventDefault();
moveSelection(0, 1);
break;
- case 'Enter':
+ case 'Enter':
e.preventDefault();
if (selectedCell) {
setEditingValue(cells[selectedCell.row][selectedCell.col].value);
@@ -193,7 +257,7 @@ const Spreadsheet: FC = () => {
}, 0);
}
break;
- case 'F2':
+ case 'F2':
e.preventDefault();
if (selectedCell) {
setEditingValue(cells[selectedCell.row][selectedCell.col].value);
@@ -251,6 +315,11 @@ const Spreadsheet: FC = () => {
{selectedCell ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : ''}
+ {isCalculating && (
+
+ Вычисляется...
+
+ )}
{
: {})
}}
onClick={() => {
+ // Если в режиме редактирования и это активная ячейка, не обрабатываем клик
+ if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex) {
+ return;
+ }
+
handleCellClick(rowIndex, colIndex);
// Фокусируемся на контейнере, чтобы работали клавиши
containerRef.current?.focus();
@@ -390,8 +464,14 @@ const Spreadsheet: FC = () => {
>
{
handleCellChange(rowIndex, colIndex, e.target.value);
setFormulaBarValue(e.target.value);
@@ -416,6 +496,11 @@ const Spreadsheet: FC = () => {
}
}}
onClick={(e) => {
+ // Если в режиме редактирования и это активная ячейка, не обрабатываем клик
+ if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex) {
+ return;
+ }
+
e.stopPropagation();
setSelectedCell({ row: rowIndex, col: colIndex });
setIsEditing(false); // Явно отключаем режим редактирования при клике
@@ -430,9 +515,22 @@ const Spreadsheet: FC = () => {
// Фокус уже на input, устанавливаем курсор в конец
setTimeout(() => {
const input = e.target as HTMLInputElement;
+ input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}}
+ onMouseDown={(e) => {
+ // В режиме редактирования разрешаем нормальное поведение мыши
+ if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex) {
+ e.stopPropagation();
+ }
+ }}
+ onMouseUp={(e) => {
+ // В режиме редактирования разрешаем нормальное поведение мыши
+ if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex) {
+ e.stopPropagation();
+ }
+ }}
readOnly={!isEditing || selectedCell?.row !== rowIndex || selectedCell?.col !== colIndex}
ref={(el) => {
if (inputRefs.current[rowIndex]) {
diff --git a/src/hooks/useSpreadsheetEngine.ts b/src/hooks/useSpreadsheetEngine.ts
new file mode 100644
index 0000000..bee5dad
--- /dev/null
+++ b/src/hooks/useSpreadsheetEngine.ts
@@ -0,0 +1,301 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { CellUtils, CellValue, Workbook } from '../lib/spreadsheet-engine';
+import { addCustomFunctions } from '../lib/spreadsheet-example';
+
+interface CellData {
+ value: string;
+ isSelected: boolean;
+}
+
+interface UseSpreadsheetEngineProps {
+ rows?: number;
+ cols?: number;
+ sheetName?: string;
+ initialData?: string[][];
+}
+
+export function useSpreadsheetEngine({
+ rows = 20,
+ cols = 10,
+ sheetName = 'Sheet1',
+ initialData
+}: UseSpreadsheetEngineProps = {}) {
+ const workbookRef = useRef(null);
+ const [cells, setCells] = useState(() => {
+ return Array(rows).fill(null).map(() =>
+ Array(cols).fill(null).map(() => ({ value: '', isSelected: false }))
+ );
+ });
+ const [isCalculating, setIsCalculating] = useState(false);
+
+ // Инициализация workbook
+ useEffect(() => {
+ if (!workbookRef.current) {
+ workbookRef.current = new Workbook();
+ workbookRef.current.addSheet(sheetName);
+ addCustomFunctions(workbookRef.current);
+ }
+
+ // Загружаем начальные данные если они есть
+ if (initialData) {
+ loadDataFromArray(initialData);
+ }
+ }, [sheetName, initialData]);
+
+ // Конвертация данных из движка в React состояние
+ const syncFromEngine = useCallback(() => {
+ if (!workbookRef.current) return;
+
+ const sheet = workbookRef.current.getSheet(sheetName);
+ if (!sheet) return;
+
+ const newCells = Array(rows).fill(null).map(() =>
+ Array(cols).fill(null).map(() => ({ value: '', isSelected: false }))
+ );
+
+ // Получаем все ячейки из движка
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const cell = sheet.getCell(cellName);
+
+ // Показываем сырое значение (raw) в UI, а не вычисленное
+ if (cell.raw !== null && cell.raw !== undefined) {
+ newCells[row][col].value = String(cell.raw);
+ }
+ }
+ }
+
+ setCells(newCells);
+ }, [rows, cols, sheetName]);
+
+ // Установка значения ячейки
+ const setCellValue = useCallback((row: number, col: number, value: string) => {
+ if (!workbookRef.current) return;
+
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const qualifiedName = `${sheetName}!${cellName}`;
+
+ setIsCalculating(true);
+
+ try {
+ // Конвертируем пустую строку в null
+ const cellValue: CellValue = value === '' ? null : value;
+
+ // Если значение начинается с =, то это формула
+ if (typeof cellValue === 'string' && cellValue.startsWith('=')) {
+ workbookRef.current.setCell(qualifiedName, cellValue);
+ } else {
+ // Пытаемся конвертировать в число, если возможно
+ const numValue = Number(cellValue);
+ if (!isNaN(numValue) && cellValue !== null && cellValue !== '') {
+ workbookRef.current.setCell(qualifiedName, numValue);
+ } else {
+ workbookRef.current.setCell(qualifiedName, cellValue);
+ }
+ }
+
+ // Пересчитываем все формулы
+ workbookRef.current.recalc();
+
+ // Синхронизируем с React состоянием
+ syncFromEngine();
+ } catch (error) {
+ console.error('Error setting cell value:', error);
+ } finally {
+ setIsCalculating(false);
+ }
+ }, [sheetName, syncFromEngine]);
+
+ // Получение вычисленного значения ячейки
+ const getCellValue = useCallback((row: number, col: number): CellValue => {
+ if (!workbookRef.current) return null;
+
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const qualifiedName = `${sheetName}!${cellName}`;
+
+ try {
+ return workbookRef.current.getValue(qualifiedName);
+ } catch (error) {
+ console.error('Error getting cell value:', error);
+ return '#ERROR!';
+ }
+ }, [sheetName]);
+
+ // Получение сырого значения ячейки (для отображения в строке формул)
+ const getCellRawValue = useCallback((row: number, col: number): string => {
+ if (!workbookRef.current) return '';
+
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const sheet = workbookRef.current.getSheet(sheetName);
+
+ if (!sheet) return '';
+
+ const cell = sheet.getCell(cellName);
+ return cell.raw !== null && cell.raw !== undefined ? String(cell.raw) : '';
+ }, [sheetName]);
+
+ // Загрузка данных из массива
+ const loadDataFromArray = useCallback((data: string[][]) => {
+ if (!workbookRef.current) return;
+
+ const sheet = workbookRef.current.getSheet(sheetName);
+ if (!sheet) return;
+
+ setIsCalculating(true);
+
+ try {
+ // Очищаем лист
+ sheet.cells.clear();
+
+ // Загружаем данные
+ for (let row = 0; row < data.length && row < rows; row++) {
+ for (let col = 0; col < data[row].length && col < cols; col++) {
+ const value = data[row][col];
+ if (value !== null && value !== undefined && value !== '') {
+ const cellName = CellUtils.createCellName(col, row + 1);
+ sheet.setCell(cellName, value);
+ }
+ }
+ }
+
+ // Пересчитываем все формулы
+ workbookRef.current.recalc();
+
+ // Синхронизируем с React состоянием
+ syncFromEngine();
+ } catch (error) {
+ console.error('Error loading data:', error);
+ } finally {
+ setIsCalculating(false);
+ }
+ }, [rows, cols, sheetName, syncFromEngine]);
+
+ // Экспорт данных в массив (для сохранения)
+ const exportToArray = useCallback((): string[][] => {
+ if (!workbookRef.current) return [];
+
+ const sheet = workbookRef.current.getSheet(sheetName);
+ if (!sheet) return [];
+
+ const result: string[][] = Array(rows).fill(null).map(() => Array(cols).fill(''));
+
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const cell = sheet.getCell(cellName);
+
+ if (cell.raw !== null && cell.raw !== undefined) {
+ result[row][col] = String(cell.raw);
+ }
+ }
+ }
+
+ return result;
+ }, [rows, cols, sheetName]);
+
+ // Экспорт в JSON для бэкенда
+ const exportToJSON = useCallback((): string => {
+ if (!workbookRef.current) return '{}';
+ return JSON.stringify(workbookRef.current.toJSON());
+ }, []);
+
+ // Импорт из JSON с бэкенда
+ const importFromJSON = useCallback((jsonData: string) => {
+ if (!workbookRef.current) return;
+
+ setIsCalculating(true);
+
+ try {
+ const data = JSON.parse(jsonData);
+ workbookRef.current.fromJSON(data);
+ addCustomFunctions(workbookRef.current);
+ workbookRef.current.recalc();
+ syncFromEngine();
+ } catch (error) {
+ console.error('Error importing from JSON:', error);
+ } finally {
+ setIsCalculating(false);
+ }
+ }, [syncFromEngine]);
+
+ // Добавление пользовательской функции
+ const addFunction = useCallback((name: string, func: (...args: any[]) => any) => {
+ if (!workbookRef.current) return;
+ workbookRef.current.addFunction(name, func);
+ }, []);
+
+ // Пересчёт всех формул
+ const recalculate = useCallback(() => {
+ if (!workbookRef.current) return;
+
+ setIsCalculating(true);
+
+ try {
+ workbookRef.current.recalc();
+ syncFromEngine();
+ } catch (error) {
+ console.error('Error recalculating:', error);
+ } finally {
+ setIsCalculating(false);
+ }
+ }, [syncFromEngine]);
+
+ // Получение информации о зависимостях ячейки
+ const getCellDependencies = useCallback((row: number, col: number) => {
+ if (!workbookRef.current) return { precedents: [], dependents: [] };
+
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const sheet = workbookRef.current.getSheet(sheetName);
+
+ if (!sheet) return { precedents: [], dependents: [] };
+
+ const cell = sheet.getCell(cellName);
+
+ return {
+ precedents: Array.from(cell.precedents),
+ dependents: Array.from(cell.dependents)
+ };
+ }, [sheetName]);
+
+ // Получение статистики workbook
+ const getWorkbookStats = useCallback(() => {
+ if (!workbookRef.current) return { sheets: 0, cells: 0, formulas: 0 };
+
+ let totalCells = 0;
+ let totalFormulas = 0;
+
+ for (const sheet of workbookRef.current.sheets.values()) {
+ totalCells += sheet.cells.size;
+
+ for (const cell of sheet.cells.values()) {
+ if (typeof cell.raw === 'string' && cell.raw.startsWith('=')) {
+ totalFormulas++;
+ }
+ }
+ }
+
+ return {
+ sheets: workbookRef.current.sheets.size,
+ cells: totalCells,
+ formulas: totalFormulas
+ };
+ }, []);
+
+ return {
+ cells,
+ setCellValue,
+ getCellValue,
+ getCellRawValue,
+ loadDataFromArray,
+ exportToArray,
+ exportToJSON,
+ importFromJSON,
+ addFunction,
+ recalculate,
+ getCellDependencies,
+ getWorkbookStats,
+ isCalculating,
+ workbook: workbookRef.current
+ };
+}
\ No newline at end of file
diff --git a/src/lib/spreadsheet-engine.ts b/src/lib/spreadsheet-engine.ts
new file mode 100644
index 0000000..d7424df
--- /dev/null
+++ b/src/lib/spreadsheet-engine.ts
@@ -0,0 +1,474 @@
+// Регулярные выражения для ссылок
+const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+\b/g; // SheetName!A1
+const LOCAL_REF_RE = /\b[A-Z]+[0-9]+\b/g; // A1, B2, etc.
+
+// Типы для значений ячеек
+export type CellValue = string | number | boolean | null | undefined;
+
+// Интерфейс для функций Excel
+export interface ExcelFunction {
+ (...args: any[]): CellValue;
+}
+
+// Волатильные функции - пересчитываются при любом изменении
+const VOLATILE_FUNCTIONS = new Set(['RAND', 'RANDBETWEEN', 'NOW', 'TODAY']);
+
+// Стандартные функции Excel
+const DEFAULT_FUNCTIONS: Record = {
+ RAND: () => Math.random(),
+ RANDBETWEEN: (bottom: number, top: number) =>
+ Math.floor(Math.random() * (top - bottom + 1)) + bottom,
+ SUM: (...args: number[]) => args.reduce((sum, val) => sum + (val || 0), 0),
+ AVERAGE: (...args: number[]) => {
+ const validArgs = args.filter(val => val !== null && val !== undefined);
+ return validArgs.length > 0 ? validArgs.reduce((sum, val) => sum + val, 0) / validArgs.length : 0;
+ },
+ COUNT: (...args: any[]) => args.filter(val => typeof val === 'number').length,
+ MAX: (...args: number[]) => Math.max(...args.filter(val => val !== null && val !== undefined)),
+ MIN: (...args: number[]) => Math.min(...args.filter(val => val !== null && val !== undefined)),
+ IF: (condition: boolean, trueValue: CellValue, falseValue: CellValue) =>
+ condition ? trueValue : falseValue,
+ AND: (...args: boolean[]) => args.every(Boolean),
+ OR: (...args: boolean[]) => args.some(Boolean),
+ NOT: (value: boolean) => !value,
+ CONCATENATE: (...args: string[]) => args.join(''),
+ LEN: (text: string) => text ? text.length : 0,
+ UPPER: (text: string) => text ? text.toUpperCase() : '',
+ LOWER: (text: string) => text ? text.toLowerCase() : '',
+ TRIM: (text: string) => text ? text.trim() : '',
+
+ // Функции округления
+ ROUND: (number: number, digits: number = 0) => {
+ const factor = Math.pow(10, digits);
+ return Math.round(number * factor) / factor;
+ },
+ ROUNDUP: (number: number, digits: number = 0) => {
+ const factor = Math.pow(10, digits);
+ return Math.ceil(number * factor) / factor;
+ },
+ ROUNDDOWN: (number: number, digits: number = 0) => {
+ const factor = Math.pow(10, digits);
+ return Math.floor(number * factor) / factor;
+ },
+ CEILING: (number: number, significance: number = 1) => {
+ if (significance === 0) return 0;
+ return Math.ceil(number / significance) * significance;
+ },
+ FLOOR: (number: number, significance: number = 1) => {
+ if (significance === 0) return 0;
+ return Math.floor(number / significance) * significance;
+ },
+ INT: (number: number) => Math.floor(number),
+ TRUNC: (number: number, digits: number = 0) => {
+ const factor = Math.pow(10, digits);
+ return Math.trunc(number * factor) / factor;
+ },
+ MROUND: (number: number, multiple: number) => {
+ if (multiple === 0) return 0;
+ return Math.round(number / multiple) * multiple;
+ },
+};
+
+export class Cell {
+ sheet: Sheet;
+ name: string;
+ raw: CellValue = null;
+ value: CellValue = null;
+ precedents: Set = new Set();
+ dependents: Set = new Set();
+ isVolatile: boolean = false; // Флаг для волатильных ячеек
+
+ constructor(sheet: Sheet, name: string) {
+ this.sheet = sheet;
+ this.name = name;
+ }
+
+ get qualifiedName(): string {
+ return `${this.sheet.name}!${this.name}`;
+ }
+
+ set(raw: CellValue): void {
+ // Удаляем старые зависимости
+ for (const ref of this.precedents) {
+ this.sheet.workbook.getCell(ref).dependents.delete(this.qualifiedName);
+ }
+
+ // Убираем ячейку из списка волатильных если она там была
+ if (this.isVolatile) {
+ this.sheet.workbook.removeVolatileCell(this.qualifiedName);
+ this.isVolatile = false;
+ }
+
+ this.raw = raw;
+ this.precedents.clear();
+
+ // Помечаем себя и всех зависимых ячеек "грязными"
+ this.sheet.workbook.markDirty(this.qualifiedName);
+ }
+
+ evaluate(): CellValue {
+ // Если не формула — просто возвращаем значение
+ if (typeof this.raw !== 'string' || !this.raw.startsWith('=')) {
+ this.value = this.raw;
+ return this.value;
+ }
+
+ const expr = this.raw.substring(1);
+
+ // Проверяем, содержит ли формула волатильные функции
+ this.checkVolatileFunctions(expr);
+
+ // Регистрируем зависимости на другие ячейки
+ // Сначала обрабатываем квалифицированные ссылки (SheetName!A1)
+ const qualifiedMatches = expr.match(QUALIFIED_REF_RE);
+ if (qualifiedMatches) {
+ for (const ref of qualifiedMatches) {
+ this.precedents.add(ref);
+ const cell = this.sheet.workbook.getCell(ref);
+ cell.dependents.add(this.qualifiedName);
+ }
+ }
+
+ // Затем обрабатываем локальные ссылки (A1, B2)
+ const localMatches = expr.match(LOCAL_REF_RE);
+ if (localMatches) {
+ for (const ref of localMatches) {
+ // Избегаем конфликтов с функциями (например, SUM, MAX)
+ if (!this.isFunction(ref)) {
+ const qualifiedRef = `${this.sheet.name}!${ref}`;
+ this.precedents.add(qualifiedRef);
+ const cell = this.sheet.workbook.getCell(qualifiedRef);
+ cell.dependents.add(this.qualifiedName);
+ }
+ }
+ }
+
+ try {
+ // Заменяем ссылки на вычисленные значения
+ let code = expr;
+
+ // Заменяем квалифицированные ссылки на ячейки
+ code = code.replace(QUALIFIED_REF_RE, (match) => {
+ const cellValue = this.sheet.workbook.getCell(match).evaluate();
+ return typeof cellValue === 'string' ? `"${cellValue}"` : String(cellValue ?? 0);
+ });
+
+ // Заменяем локальные ссылки на ячейки
+ code = code.replace(LOCAL_REF_RE, (match) => {
+ // Избегаем замены функций
+ if (this.isFunction(match)) {
+ return match;
+ }
+ const qualifiedRef = `${this.sheet.name}!${match}`;
+ const cellValue = this.sheet.workbook.getCell(qualifiedRef).evaluate();
+ return typeof cellValue === 'string' ? `"${cellValue}"` : String(cellValue ?? 0);
+ });
+
+ // Заменяем функции Excel
+ const functions = this.sheet.workbook.getFunctions();
+ for (const [funcName, func] of Object.entries(functions)) {
+ const funcRegex = new RegExp(`\\b${funcName}\\s*\\(`, 'gi');
+ code = code.replace(funcRegex, `__functions__.${funcName}(`);
+ }
+
+ // Создаём контекст для выполнения
+ const context = {
+ __functions__: functions,
+ wb: this.sheet.workbook,
+ };
+
+ // Выполняем код в безопасном контексте
+ this.value = this.evaluateExpression(code, context);
+ return this.value;
+ } catch (error) {
+ console.error(`Error evaluating formula in ${this.qualifiedName}:`, error);
+ this.value = '#ERROR!';
+ return this.value;
+ }
+ }
+
+ private checkVolatileFunctions(expr: string): void {
+ // Проверяем, содержит ли формула волатильные функции
+ const functions = this.sheet.workbook.getFunctions();
+ let hasVolatileFunction = false;
+
+ for (const funcName of Object.keys(functions)) {
+ if (VOLATILE_FUNCTIONS.has(funcName.toUpperCase())) {
+ const funcRegex = new RegExp(`\\b${funcName}\\s*\\(`, 'gi');
+ if (funcRegex.test(expr)) {
+ hasVolatileFunction = true;
+ break;
+ }
+ }
+ }
+
+ if (hasVolatileFunction && !this.isVolatile) {
+ this.isVolatile = true;
+ this.sheet.workbook.addVolatileCell(this.qualifiedName);
+ } else if (!hasVolatileFunction && this.isVolatile) {
+ this.isVolatile = false;
+ this.sheet.workbook.removeVolatileCell(this.qualifiedName);
+ }
+ }
+
+ private isFunction(ref: string): boolean {
+ const functions = this.sheet.workbook.getFunctions();
+ return Object.keys(functions).includes(ref.toUpperCase());
+ }
+
+ private evaluateExpression(expr: string, context: any): CellValue {
+ // Простая реализация eval для математических выражений и функций
+ // В реальном проекте лучше использовать специализированную библиотеку
+ // для парсинга и выполнения формул
+
+ try {
+ // Создаём функцию с контекстом
+ const func = new Function(
+ ...Object.keys(context),
+ `return ${expr}`
+ );
+
+ return func(...Object.values(context));
+ } catch (error) {
+ throw new Error(`Invalid expression: ${expr}`);
+ }
+ }
+}
+
+export class Sheet {
+ name: string;
+ workbook: Workbook;
+ cells: Map = new Map();
+
+ constructor(name: string, workbook: Workbook) {
+ this.name = name;
+ this.workbook = workbook;
+ }
+
+ getCell(name: string): Cell {
+ if (!this.cells.has(name)) {
+ this.cells.set(name, new Cell(this, name));
+ }
+ return this.cells.get(name)!;
+ }
+
+ setCell(name: string, raw: CellValue): void {
+ const cell = this.getCell(name);
+ cell.set(raw);
+ }
+
+ getCellValue(name: string): CellValue {
+ return this.getCell(name).value;
+ }
+
+ getAllCells(): Map {
+ return this.cells;
+ }
+
+ toJSON(): Record {
+ const result: Record = {};
+ for (const [name, cell] of this.cells) {
+ if (cell.raw !== null && cell.raw !== undefined && cell.raw !== '') {
+ result[name] = cell.raw;
+ }
+ }
+ return result;
+ }
+
+ fromJSON(data: Record): void {
+ for (const [name, value] of Object.entries(data)) {
+ this.setCell(name, value);
+ }
+ }
+}
+
+export class Workbook {
+ sheets: Map = new Map();
+ private _dirty: Set = new Set();
+ private _functions: Record = { ...DEFAULT_FUNCTIONS };
+ private _volatileCells: Set = new Set(); // Список волатильных ячеек
+
+ addSheet(name: string): Sheet {
+ const sheet = new Sheet(name, this);
+ this.sheets.set(name, sheet);
+ return sheet;
+ }
+
+ getSheet(name: string): Sheet | undefined {
+ return this.sheets.get(name);
+ }
+
+ getCell(qualifiedName: string): Cell {
+ const [sheetName, cellName] = qualifiedName.split('!');
+ const sheet = this.sheets.get(sheetName);
+ if (!sheet) {
+ throw new Error(`Sheet "${sheetName}" not found`);
+ }
+ return sheet.getCell(cellName);
+ }
+
+ setCell(qualifiedName: string, raw: CellValue): void {
+ const cell = this.getCell(qualifiedName);
+ cell.set(raw);
+ }
+
+ markDirty(qualifiedName: string): void {
+ const toVisit = new Set([qualifiedName]);
+
+ while (toVisit.size > 0) {
+ const current = toVisit.values().next().value;
+ toVisit.delete(current);
+
+ if (!this._dirty.has(current)) {
+ this._dirty.add(current);
+ // Добавляем всех зависимых ячеек
+ for (const dep of this.getCell(current).dependents) {
+ toVisit.add(dep);
+ }
+ }
+ }
+
+ // Также помечаем все волатильные ячейки как "грязные"
+ for (const volatileCell of this._volatileCells) {
+ if (!this._dirty.has(volatileCell)) {
+ this._dirty.add(volatileCell);
+ // Добавляем всех зависимых от волатильных ячеек
+ for (const dep of this.getCell(volatileCell).dependents) {
+ toVisit.add(dep);
+ }
+ }
+ }
+
+ // Обрабатываем зависимых от волатильных ячеек
+ while (toVisit.size > 0) {
+ const current = toVisit.values().next().value;
+ toVisit.delete(current);
+
+ if (!this._dirty.has(current)) {
+ this._dirty.add(current);
+ // Добавляем всех зависимых ячеек
+ for (const dep of this.getCell(current).dependents) {
+ toVisit.add(dep);
+ }
+ }
+ }
+ }
+
+ recalc(): void {
+ // Пересчёт всех "грязных" ячеек
+ while (this._dirty.size > 0) {
+ const current = this._dirty.values().next().value;
+ this._dirty.delete(current);
+ this.getCell(current).evaluate();
+ }
+ }
+
+ getValue(qualifiedName: string): CellValue {
+ if (this._dirty.has(qualifiedName)) {
+ this.recalc();
+ }
+ return this.getCell(qualifiedName).value;
+ }
+
+ // Управление волатильными ячейками
+ addVolatileCell(qualifiedName: string): void {
+ this._volatileCells.add(qualifiedName);
+ }
+
+ removeVolatileCell(qualifiedName: string): void {
+ this._volatileCells.delete(qualifiedName);
+ }
+
+ getVolatileCells(): Set {
+ return new Set(this._volatileCells);
+ }
+
+ // Управление функциями
+ addFunction(name: string, func: ExcelFunction): void {
+ this._functions[name.toUpperCase()] = func;
+
+ // Если добавляем волатильную функцию, обновляем список
+ if (VOLATILE_FUNCTIONS.has(name.toUpperCase())) {
+ this.markAllVolatileCellsDirty();
+ }
+ }
+
+ removeFunction(name: string): void {
+ delete this._functions[name.toUpperCase()];
+ }
+
+ getFunctions(): Record {
+ return this._functions;
+ }
+
+ // Помечаем все волатильные ячейки как "грязные"
+ private markAllVolatileCellsDirty(): void {
+ for (const volatileCell of this._volatileCells) {
+ this.markDirty(volatileCell);
+ }
+ }
+
+ // Сериализация
+ toJSON(): Record> {
+ const result: Record> = {};
+ for (const [name, sheet] of this.sheets) {
+ result[name] = sheet.toJSON();
+ }
+ return result;
+ }
+
+ fromJSON(data: Record>): void {
+ for (const [sheetName, sheetData] of Object.entries(data)) {
+ if (!this.sheets.has(sheetName)) {
+ this.addSheet(sheetName);
+ }
+ this.sheets.get(sheetName)!.fromJSON(sheetData);
+ }
+ }
+
+ // Очистка всех данных
+ clear(): void {
+ this.sheets.clear();
+ this._dirty.clear();
+ this._volatileCells.clear();
+ }
+}
+
+// Утилиты для работы с координатами ячеек
+export class CellUtils {
+ static columnToIndex(column: string): number {
+ let result = 0;
+ for (let i = 0; i < column.length; i++) {
+ result = result * 26 + (column.charCodeAt(i) - 65 + 1);
+ }
+ return result - 1;
+ }
+
+ static indexToColumn(index: number): string {
+ let result = '';
+ while (index >= 0) {
+ result = String.fromCharCode(65 + (index % 26)) + result;
+ index = Math.floor(index / 26) - 1;
+ }
+ return result;
+ }
+
+ static parseCellName(cellName: string): { column: string; row: number } {
+ const match = cellName.match(/^([A-Z]+)(\d+)$/);
+ if (!match) {
+ throw new Error(`Invalid cell name: ${cellName}`);
+ }
+ return {
+ column: match[1],
+ row: parseInt(match[2], 10),
+ };
+ }
+
+ static createCellName(column: string | number, row: number): string {
+ const col = typeof column === 'number' ? CellUtils.indexToColumn(column) : column;
+ return `${col}${row}`;
+ }
+}
+
+// Экспорт для удобства использования
+export { DEFAULT_FUNCTIONS, VOLATILE_FUNCTIONS };
diff --git a/src/lib/spreadsheet-example.ts b/src/lib/spreadsheet-example.ts
new file mode 100644
index 0000000..5f50d99
--- /dev/null
+++ b/src/lib/spreadsheet-example.ts
@@ -0,0 +1,191 @@
+import { CellUtils, Workbook } from './spreadsheet-engine';
+
+// Пример использования движка электронной таблицы
+export function createExampleWorkbook(): Workbook {
+ const wb = new Workbook();
+
+ // Создаём листы
+ const sheetMain = wb.addSheet('Sheet1');
+
+ // Заполняем данные с локальными ссылками
+ wb.setCell('Sheet1!A1', 10);
+ wb.setCell('Sheet1!A2', 20);
+ wb.setCell('Sheet1!A3', '=A1+A2'); // Локальная ссылка
+ wb.setCell('Sheet1!B1', '=A1*2'); // Локальная ссылка
+ wb.setCell('Sheet1!B2', '=SUM(A1,A2)'); // Функция с локальными ссылками
+ wb.setCell('Sheet1!C1', '="Результат: " + A3'); // Строковая операция с локальной ссылкой
+
+ // Пример с квалифицированными ссылками (если есть несколько листов)
+ const countsSheet = wb.addSheet('Counts');
+ wb.setCell('Counts!A1', 'N455');
+ wb.setCell('Sheet1!D1', '="Отчет по протоколу " + Counts!A1');
+
+ // Пересчитываем все формулы
+ wb.recalc();
+
+ return wb;
+}
+
+// Пример добавления пользовательской функции
+export function addCustomFunctions(wb: Workbook): void {
+ // Функция для округления
+ wb.addFunction('ROUND', (number: number, digits: number = 0): number => {
+ const factor = Math.pow(10, digits);
+ return Math.round(number * factor) / factor;
+ });
+
+ // Функция для проверки на пустое значение
+ wb.addFunction('ISEMPTY', (value: any): boolean => {
+ return value === null || value === undefined || value === '';
+ });
+
+ // Волатильные функции для работы с датой и временем
+ wb.addFunction('TODAY', (): string => {
+ return new Date().toLocaleDateString('ru-RU');
+ });
+
+ wb.addFunction('NOW', (): string => {
+ return new Date().toLocaleString('ru-RU');
+ });
+
+ // Функция для работы с текстом
+ wb.addFunction('LEFT', (text: string, numChars: number): string => {
+ return text ? text.substring(0, numChars) : '';
+ });
+
+ wb.addFunction('RIGHT', (text: string, numChars: number): string => {
+ return text ? text.substring(text.length - numChars) : '';
+ });
+
+ wb.addFunction('MID', (text: string, startPos: number, numChars: number): string => {
+ return text ? text.substring(startPos - 1, startPos - 1 + numChars) : '';
+ });
+
+ // Математические функции
+ wb.addFunction('ABS', (number: number): number => Math.abs(number));
+ wb.addFunction('SQRT', (number: number): number => Math.sqrt(number));
+ wb.addFunction('POWER', (base: number, exponent: number): number => Math.pow(base, exponent));
+ wb.addFunction('MOD', (dividend: number, divisor: number): number => dividend % divisor);
+
+ // Функции для работы с датами
+ wb.addFunction('YEAR', (dateString: string): number => {
+ const date = new Date(dateString);
+ return date.getFullYear();
+ });
+
+ wb.addFunction('MONTH', (dateString: string): number => {
+ const date = new Date(dateString);
+ return date.getMonth() + 1; // JavaScript months are 0-based
+ });
+
+ wb.addFunction('DAY', (dateString: string): number => {
+ const date = new Date(dateString);
+ return date.getDate();
+ });
+}
+
+// Пример интеграции с React компонентом
+export interface SpreadsheetData {
+ [sheetName: string]: {
+ [cellName: string]: any;
+ };
+}
+
+// Функция для конвертации данных из React компонента в движок
+export function convertFromReactData(reactData: string[][], sheetName: string = 'Sheet1'): Workbook {
+ const wb = new Workbook();
+ const sheet = wb.addSheet(sheetName);
+
+ for (let row = 0; row < reactData.length; row++) {
+ for (let col = 0; col < reactData[row].length; col++) {
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const value = reactData[row][col];
+
+ if (value !== null && value !== undefined && value !== '') {
+ sheet.setCell(cellName, value);
+ }
+ }
+ }
+
+ wb.recalc();
+ return wb;
+}
+
+// Функция для конвертации данных из движка в React компонент
+export function convertToReactData(wb: Workbook, sheetName: string = 'Sheet1', maxRows: number = 20, maxCols: number = 10): string[][] {
+ const sheet = wb.getSheet(sheetName);
+ if (!sheet) {
+ return Array(maxRows).fill(null).map(() => Array(maxCols).fill(''));
+ }
+
+ const result: string[][] = Array(maxRows).fill(null).map(() => Array(maxCols).fill(''));
+
+ for (let row = 0; row < maxRows; row++) {
+ for (let col = 0; col < maxCols; col++) {
+ const cellName = CellUtils.createCellName(col, row + 1);
+ const cell = sheet.getCell(cellName);
+ const value = cell.value;
+
+ if (value !== null && value !== undefined) {
+ result[row][col] = String(value);
+ }
+ }
+ }
+
+ return result;
+}
+
+// Функция для сохранения в JSON для бэкенда
+export function saveToBackend(wb: Workbook): string {
+ return JSON.stringify(wb.toJSON());
+}
+
+// Функция для загрузки из JSON с бэкенда
+export function loadFromBackend(jsonData: string): Workbook {
+ const wb = new Workbook();
+ const data = JSON.parse(jsonData);
+ wb.fromJSON(data);
+ addCustomFunctions(wb); // Добавляем пользовательские функции
+ wb.recalc(); // Пересчитываем все формулы
+ return wb;
+}
+
+// Пример использования для тестирования
+export function runExample(): void {
+ console.log('=== Пример использования движка электронной таблицы ===');
+
+ // Создаём workbook
+ const wb = createExampleWorkbook();
+ addCustomFunctions(wb);
+
+ // Выводим результаты с локальными ссылками
+ console.log('Sheet1!A1:', wb.getValue('Sheet1!A1')); // 10
+ console.log('Sheet1!A2:', wb.getValue('Sheet1!A2')); // 20
+ console.log('Sheet1!A3:', wb.getValue('Sheet1!A3')); // 30 (A1+A2)
+ console.log('Sheet1!B1:', wb.getValue('Sheet1!B1')); // 20 (A1*2)
+ console.log('Sheet1!B2:', wb.getValue('Sheet1!B2')); // 30 (SUM(A1,A2))
+ console.log('Sheet1!C1:', wb.getValue('Sheet1!C1')); // "Результат: 30"
+ console.log('Sheet1!D1:', wb.getValue('Sheet1!D1')); // "Отчет по протоколу N455"
+
+ // Пример добавления новых данных с локальными ссылками
+ wb.setCell('Sheet1!E1', '=A1+B1+A3');
+ wb.setCell('Sheet1!E2', '=CONCATENATE("Общая сумма: ", E1)');
+
+ console.log('Sheet1!E1:', wb.getValue('Sheet1!E1')); // 60
+ console.log('Sheet1!E2:', wb.getValue('Sheet1!E2')); // "Общая сумма: 60"
+
+ // Пример использования пользовательских функций
+ wb.setCell('Sheet1!F1', '=ROUND(3.14159, 2)');
+ wb.setCell('Sheet1!F2', '=TODAY()');
+
+ console.log('Sheet1!F1:', wb.getValue('Sheet1!F1'));
+ console.log('Sheet1!F2:', wb.getValue('Sheet1!F2'));
+
+ // Сериализация для бэкенда
+ const serialized = saveToBackend(wb);
+ console.log('Serialized data length:', serialized.length);
+
+ // Десериализация с бэкенда
+ const loaded = loadFromBackend(serialized);
+ console.log('Loaded Sheet1!A3:', loaded.getValue('Sheet1!A3'));
+}
\ No newline at end of file