This commit is contained in:
Artyom Tsyrulnikov
2025-07-13 13:08:01 +03:00
parent 14bff20150
commit 36bdb4b2be
6 changed files with 1074 additions and 11 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,4 +1,5 @@
import { FC, useEffect, useRef, useState } from 'react';
import { useSpreadsheetEngine } from '../../hooks/useSpreadsheetEngine';
interface CellData {
value: string;
@@ -6,6 +7,19 @@ interface CellData {
}
const Spreadsheet: FC = () => {
const {
cells: engineCells,
setCellValue: setEngineCellValue,
getCellValue,
getCellRawValue,
isCalculating,
recalculate
} = useSpreadsheetEngine({
rows: 20,
cols: 10,
sheetName: 'Sheet1'
});
const [cells, setCells] = useState<CellData[][]>(() => {
// Создаем сетку 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) => {
@@ -251,6 +315,11 @@ const Spreadsheet: FC = () => {
<div className="text-sm text-gray-600 min-w-[60px]">
{selectedCell ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : ''}
</div>
{isCalculating && (
<div className="text-xs text-blue-600 animate-pulse">
Вычисляется...
</div>
)}
<div className="flex-1">
<input
ref={formulaBarRef}
@@ -382,6 +451,11 @@ const Spreadsheet: FC = () => {
: {})
}}
onClick={() => {
// Если в режиме редактирования и это активная ячейка, не обрабатываем клик
if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex) {
return;
}
handleCellClick(rowIndex, colIndex);
// Фокусируемся на контейнере, чтобы работали клавиши
containerRef.current?.focus();
@@ -390,8 +464,14 @@ const Spreadsheet: FC = () => {
>
<input
type="text"
className="w-full h-full px-1 text-sm bg-transparent border-none outline-none resize-none cursor-text"
value={cell.value}
className={`w-full h-full px-1 text-sm bg-transparent border-none outline-none resize-none ${
isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex
? 'cursor-text'
: 'cursor-cell'
}`}
value={isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex
? cell.value
: String(getCellValue(rowIndex, colIndex) || '')}
onChange={(e) => {
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]) {

View File

@@ -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<Workbook | null>(null);
const [cells, setCells] = useState<CellData[][]>(() => {
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
};
}

View File

@@ -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<string, ExcelFunction> = {
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<string> = new Set();
dependents: Set<string> = 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<string, Cell> = 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<string, Cell> {
return this.cells;
}
toJSON(): Record<string, CellValue> {
const result: Record<string, CellValue> = {};
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<string, CellValue>): void {
for (const [name, value] of Object.entries(data)) {
this.setCell(name, value);
}
}
}
export class Workbook {
sheets: Map<string, Sheet> = new Map();
private _dirty: Set<string> = new Set();
private _functions: Record<string, ExcelFunction> = { ...DEFAULT_FUNCTIONS };
private _volatileCells: Set<string> = 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<string> {
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<string, ExcelFunction> {
return this._functions;
}
// Помечаем все волатильные ячейки как "грязные"
private markAllVolatileCellsDirty(): void {
for (const volatileCell of this._volatileCells) {
this.markDirty(volatileCell);
}
}
// Сериализация
toJSON(): Record<string, Record<string, CellValue>> {
const result: Record<string, Record<string, CellValue>> = {};
for (const [name, sheet] of this.sheets) {
result[name] = sheet.toJSON();
}
return result;
}
fromJSON(data: Record<string, Record<string, CellValue>>): 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 };

View File

@@ -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'));
}