рефакторинг

This commit is contained in:
2025-07-15 16:06:10 +03:00
parent df12bde43f
commit 90ebcd3772
14 changed files with 1722 additions and 1867 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -93,7 +93,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
Редактирование шаблона: {editedTemplate.name}
</h1>
<p className="text-sm text-gray-600 mt-1">
Настройте Excel шаблон и создайте элементы интерфейса
Настройте Excel шаблон и значения отчета
</p>
</div>
</div>

View File

@@ -45,19 +45,19 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({ onTemplateSele
setSelectedTemplate(updatedTemplate);
};
const handleTemplateDelete = (templateId: string) => {
deleteTemplate(templateId);
if (selectedTemplate?.id === templateId) {
setSelectedTemplate(null);
setIsEditMode(false);
}
};
// const handleTemplateDelete = (templateId: string) => {
// deleteTemplate(templateId);
// if (selectedTemplate?.id === templateId) {
// setSelectedTemplate(null);
// setIsEditMode(false);
// }
// };
const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template);
setIsEditMode(false);
onTemplateSelect?.(template);
};
// const handleTemplateSelect = (template: Template) => {
// setSelectedTemplate(template);
// setIsEditMode(false);
// onTemplateSelect?.(template);
// };
const handleEditTemplate = (template: Template) => {
setSelectedTemplate(template);
@@ -191,7 +191,7 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({ onTemplateSele
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
</div>
</div>
<div className="flex gap-2 mt-4">
{/* <div className="flex gap-2 mt-4">
<Button
variant="outline"
size="sm"
@@ -200,7 +200,7 @@ export const TemplateManager: React.FC<TemplateManagerProps> = ({ onTemplateSele
>
Предпросмотр
</Button>
</div>
</div> */}
</CardContent>
</Card>
))}

View File

@@ -1,239 +1,252 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CellValue, Workbook } from '../lib/spreadsheet-engine';
import { useCallback, useEffect, useRef, useState } from 'react'
import {
CellValue,
Workbook,
} from '../lib/spreadsheet-engine/spreadsheet-engine'
interface CellData {
value: string;
isSelected: boolean;
value: string
isSelected: boolean
}
interface SheetConfig {
name: string;
rows: number;
cols: number;
name: string
rows: number
cols: number
}
interface UseMultiSheetEngineProps {
sheets: SheetConfig[];
initialData?: Record<string, Record<string, any>>;
sheets: SheetConfig[]
initialData?: Record<string, Record<string, any>>
}
export function useMultiSheetEngine({
sheets,
initialData = {}
export function useMultiSheetEngine({
sheets,
initialData = {},
}: UseMultiSheetEngineProps) {
const workbookRef = useRef<Workbook | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const [sheetCells, setSheetCells] = useState<Record<string, CellData[][]>>({});
const debouncedRecalcTimeoutRef = useRef<number | null>(null);
const isRecalculatingRef = useRef(false);
const workbookRef = useRef<Workbook | null>(null)
const [isCalculating, setIsCalculating] = useState(false)
const [revision, setRevision] = useState(0) // Новый счетчик для обновлений
const debouncedRecalcTimeoutRef = useRef<number | null>(null)
const isRecalculatingRef = useRef(false)
// Инициализация workbook и листов
useEffect(() => {
if (!workbookRef.current) {
workbookRef.current = new Workbook();
// Создаем листы
sheets.forEach(sheetConfig => {
workbookRef.current!.addSheet(sheetConfig.name);
});
workbookRef.current = new Workbook()
// Инициализируем состояние ячеек для каждого листа
const initialSheetCells: Record<string, CellData[][]> = {};
sheets.forEach(sheetConfig => {
initialSheetCells[sheetConfig.name] = Array(sheetConfig.rows).fill(null).map(() =>
Array(sheetConfig.cols).fill(null).map(() => ({ value: '', isSelected: false }))
);
});
setSheetCells(initialSheetCells);
// Создаем листы
sheets.forEach((sheetConfig) => {
workbookRef.current!.addSheet(sheetConfig.name)
})
// Загружаем начальные данные
if (Object.keys(initialData).length > 0) {
workbookRef.current.fromJSON(initialData);
syncAllSheetsFromEngine();
workbookRef.current.fromJSON(initialData)
// Первый пересчет для инициализации всех значений
safeRecalc()
} else {
// Увеличиваем ревизию, чтобы вызвать начальную отрисовку
setRevision((r) => r + 1)
}
}
}, [sheets, initialData]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Зависимости убраны, чтобы инициализация проходила один раз
// Синхронизация всех листов из движка
const syncAllSheetsFromEngine = useCallback(() => {
if (!workbookRef.current || isRecalculatingRef.current) return;
if (!workbookRef.current || isRecalculatingRef.current) return
const newSheetCells: Record<string, CellData[][]> = {};
sheets.forEach(sheetConfig => {
const sheet = workbookRef.current!.getSheet(sheetConfig.name);
if (!sheet) return;
const newSheetCells: Record<string, CellData[][]> = {}
const cells = Array(sheetConfig.rows).fill(null).map(() =>
Array(sheetConfig.cols).fill(null).map(() => ({ value: '', isSelected: false }))
);
sheets.forEach((sheetConfig) => {
const sheet = workbookRef.current!.getSheet(sheetConfig.name)
if (!sheet) return
const cells = Array(sheetConfig.rows)
.fill(null)
.map(() =>
Array(sheetConfig.cols)
.fill(null)
.map(() => ({ value: '', isSelected: false })),
)
// Получаем все ячейки из движка
for (let row = 0; row < sheetConfig.rows; row++) {
for (let col = 0; col < sheetConfig.cols; col++) {
const cellName = columnToLabel(col) + (row + 1);
const cell = sheet.getCell(cellName);
const cellName = columnToLabel(col) + (row + 1)
const cell = sheet.getCell(cellName)
if (cell.raw !== null && cell.raw !== undefined) {
cells[row][col].value = String(cell.raw);
cells[row][col].value = String(cell.raw)
}
}
}
newSheetCells[sheetConfig.name] = cells;
});
newSheetCells[sheetConfig.name] = cells
})
setSheetCells(newSheetCells);
}, [sheets]);
// setSheetCells(newSheetCells) // Удалено, так как sheetCells убрано
}, [sheets])
// Безопасная функция для вычислений
const safeRecalc = useCallback(() => {
if (!workbookRef.current || isRecalculatingRef.current) return;
if (!workbookRef.current || isRecalculatingRef.current) return
isRecalculatingRef.current = true;
setIsCalculating(true);
isRecalculatingRef.current = true
setIsCalculating(true)
try {
workbookRef.current.recalc();
syncAllSheetsFromEngine();
workbookRef.current.recalc()
} catch (error) {
console.warn('Ошибка при вычислении:', error);
console.warn('Ошибка при вычислении:', error)
} finally {
// Небольшая задержка для визуального индикатора
setTimeout(() => {
setIsCalculating(false);
isRecalculatingRef.current = false;
}, 150);
setIsCalculating(false)
isRecalculatingRef.current = false
// Увеличиваем ревизию, чтобы сообщить компонентам о необходимости обновления
setRevision((r) => r + 1)
}, 150)
}
}, [syncAllSheetsFromEngine]);
}, [])
// Отложенный пересчет с дебаунсом
const debouncedRecalc = useCallback(() => {
if (!workbookRef.current || isRecalculatingRef.current) return;
if (!workbookRef.current || isRecalculatingRef.current) return
// Очищаем предыдущий отложенный пересчет
if (debouncedRecalcTimeoutRef.current) {
clearTimeout(debouncedRecalcTimeoutRef.current);
clearTimeout(debouncedRecalcTimeoutRef.current)
}
// Запускаем пересчет через 300ms
debouncedRecalcTimeoutRef.current = setTimeout(() => {
safeRecalc();
}, 300);
}, [safeRecalc]);
safeRecalc()
}, 300)
}, [safeRecalc])
// Установка значения ячейки без немедленного пересчета
const setCellValueWithoutRecalc = useCallback((sheetName: string, row: number, col: number, value: string) => {
if (!workbookRef.current || isRecalculatingRef.current) return;
const setCellValueWithoutRecalc = useCallback(
(sheetName: string, row: number, col: number, value: string) => {
if (!workbookRef.current || isRecalculatingRef.current) return
const cellName = columnToLabel(col) + (row + 1);
const qualifiedName = `${sheetName}!${cellName}`;
// Устанавливаем значение в движке без пересчета
workbookRef.current.setCell(qualifiedName, value);
// Обновляем только локальное состояние ячейки
setSheetCells(prev => {
const newSheetCells = { ...prev };
if (newSheetCells[sheetName]) {
const newCells = [...newSheetCells[sheetName]];
newCells[row] = [...newCells[row]];
newCells[row][col] = { ...newCells[row][col], value };
newSheetCells[sheetName] = newCells;
}
return newSheetCells;
});
}, []);
const cellName = columnToLabel(col) + (row + 1)
const qualifiedName = `${sheetName}!${cellName}`
// Устанавливаем значение в движке без пересчета
workbookRef.current.setCell(qualifiedName, value)
},
[],
)
// Установка значения ячейки с немедленным пересчетом
const setCellValue = useCallback((sheetName: string, row: number, col: number, value: string) => {
if (!workbookRef.current || isRecalculatingRef.current) return;
const setCellValue = useCallback(
(sheetName: string, row: number, col: number, value: string) => {
if (!workbookRef.current || isRecalculatingRef.current) return
const cellName = columnToLabel(col) + (row + 1);
const qualifiedName = `${sheetName}!${cellName}`;
// Устанавливаем значение в движке
workbookRef.current.setCell(qualifiedName, value);
// Запускаем безопасный пересчет
safeRecalc();
}, [safeRecalc]);
const cellName = columnToLabel(col) + (row + 1)
const qualifiedName = `${sheetName}!${cellName}`
// Устанавливаем значение в движке
workbookRef.current.setCell(qualifiedName, value)
// Запускаем безопасный пересчет
safeRecalc()
},
[safeRecalc],
)
// Получение значения ячейки (вычисленное)
const getCellValue = useCallback((sheetName: string, row: number, col: number): CellValue => {
if (!workbookRef.current) return '';
const getCellValue = useCallback(
(sheetName: string, row: number, col: number): CellValue => {
if (!workbookRef.current) return ''
const cellName = columnToLabel(col) + (row + 1);
const qualifiedName = `${sheetName}!${cellName}`;
return workbookRef.current.getValue(qualifiedName);
}, []);
const cellName = columnToLabel(col) + (row + 1)
const qualifiedName = `${sheetName}!${cellName}`
return workbookRef.current.getValue(qualifiedName)
},
[],
)
// Получение сырого значения ячейки (формула)
const getCellRawValue = useCallback((sheetName: string, row: number, col: number): string => {
if (!workbookRef.current) return '';
const getCellRawValue = useCallback(
(sheetName: string, row: number, col: number): string => {
if (!workbookRef.current) return ''
const sheet = workbookRef.current.getSheet(sheetName);
if (!sheet) return '';
const sheet = workbookRef.current.getSheet(sheetName)
if (!sheet) return ''
const cellName = columnToLabel(col) + (row + 1);
const cell = sheet.getCell(cellName);
return String(cell.raw || '');
}, []);
const cellName = columnToLabel(col) + (row + 1)
const cell = sheet.getCell(cellName)
return String(cell.raw || '')
},
[],
)
// Получение всех измененных ячеек для листа
const getModifiedCells = useCallback((sheetName: string): Record<string, CellValue> => {
if (!workbookRef.current) return {};
const getModifiedCells = useCallback(
(sheetName: string): Record<string, CellValue> => {
if (!workbookRef.current) return {}
const sheet = workbookRef.current.getSheet(sheetName);
if (!sheet) return {};
const sheet = workbookRef.current.getSheet(sheetName)
if (!sheet) return {}
return sheet.toJSON();
}, []);
return sheet.toJSON()
},
[],
)
// Получение всех измененных ячеек для всех листов
const getAllModifiedCells = useCallback((): Record<string, Record<string, CellValue>> => {
if (!workbookRef.current) return {};
const getAllModifiedCells = useCallback((): Record<
string,
Record<string, CellValue>
> => {
if (!workbookRef.current) return {}
return workbookRef.current.toJSON();
}, []);
return workbookRef.current.toJSON()
}, [])
// Очистка всех данных
const clearAllData = useCallback(() => {
if (!workbookRef.current) return;
if (!workbookRef.current) return
workbookRef.current.clear();
syncAllSheetsFromEngine();
}, [syncAllSheetsFromEngine]);
workbookRef.current.clear()
setRevision((r) => r + 1)
}, [])
// Загрузка данных из JSON
const loadData = useCallback((data: Record<string, Record<string, any>>) => {
if (!workbookRef.current) return;
const loadData = useCallback(
(data: Record<string, Record<string, any>>) => {
if (!workbookRef.current) return
workbookRef.current.fromJSON(data);
syncAllSheetsFromEngine();
}, [syncAllSheetsFromEngine]);
workbookRef.current.fromJSON(data)
safeRecalc()
},
[safeRecalc],
)
// Пересчет всех формул
const recalculate = useCallback(() => {
if (!workbookRef.current) return;
if (!workbookRef.current) return
safeRecalc();
}, [safeRecalc]);
safeRecalc()
}, [safeRecalc])
// Очистка таймаутов при размонтировании
useEffect(() => {
return () => {
if (debouncedRecalcTimeoutRef.current) {
clearTimeout(debouncedRecalcTimeoutRef.current);
clearTimeout(debouncedRecalcTimeoutRef.current)
}
isRecalculatingRef.current = false;
};
}, []);
isRecalculatingRef.current = false
}
}, [])
return {
sheetCells,
// sheetCells убран
setCellValue,
setCellValueWithoutRecalc,
debouncedRecalc,
@@ -245,11 +258,12 @@ export function useMultiSheetEngine({
loadData,
recalculate,
isCalculating,
workbook: workbookRef.current
};
workbook: workbookRef.current,
revision, // Экспортируем ревизию
}
}
// Вспомогательная функция для конвертации индекса колонки в букву
function columnToLabel(index: number): string {
return String.fromCharCode(65 + index);
}
return String.fromCharCode(65 + index)
}

View File

@@ -1,286 +1,321 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CellUtils, CellValue, Workbook } from '../lib/spreadsheet-engine';
import { addCustomFunctions } from '../lib/spreadsheet-example';
import { useCallback, useEffect, useRef, useState } from 'react'
import {
CellUtils,
CellValue,
Workbook,
} from '../lib/spreadsheet-engine/spreadsheet-engine'
import { addCustomFunctions } from '../lib/spreadsheet-example'
interface CellData {
value: string;
isSelected: boolean;
value: string
isSelected: boolean
}
interface UseSpreadsheetEngineProps {
rows?: number;
cols?: number;
sheetName?: string;
initialData?: string[][];
rows?: number
cols?: number
sheetName?: string
initialData?: string[][]
}
export function useSpreadsheetEngine({
rows = 50,
cols = 20,
sheetName = 'Sheet1',
initialData
initialData,
}: UseSpreadsheetEngineProps = {}) {
const workbookRef = useRef<Workbook | null>(null);
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);
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);
workbookRef.current = new Workbook()
workbookRef.current.addSheet(sheetName)
addCustomFunctions(workbookRef.current)
}
// Загружаем начальные данные если они есть
if (initialData) {
loadDataFromArray(initialData);
loadDataFromArray(initialData)
}
}, [sheetName, initialData]);
}, [sheetName, initialData])
// Конвертация данных из движка в React состояние
const syncFromEngine = useCallback(() => {
if (!workbookRef.current) return;
if (!workbookRef.current) return
const sheet = workbookRef.current.getSheet(sheetName);
if (!sheet) 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 }))
);
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);
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);
newCells[row][col].value = String(cell.raw)
}
}
}
setCells(newCells);
}, [rows, cols, sheetName]);
setCells(newCells)
}, [rows, cols, sheetName])
// Установка значения ячейки
const setCellValue = useCallback((row: number, col: number, value: string) => {
if (!workbookRef.current) return;
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);
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 {
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);
// Пытаемся конвертировать в число, если возможно
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)
}
// Пересчитываем все формулы
workbookRef.current.recalc();
// Синхронизируем с React состоянием
syncFromEngine();
} catch (error) {
console.error('Error loading data:', error);
} finally {
setIsCalculating(false);
}
}, [rows, cols, sheetName, syncFromEngine]);
},
[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 [];
if (!workbookRef.current) return []
const sheet = workbookRef.current.getSheet(sheetName);
if (!sheet) return [];
const sheet = workbookRef.current.getSheet(sheetName)
if (!sheet) return []
const result: string[][] = Array(rows)
.fill(null)
.map(() => Array(cols).fill(''))
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);
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);
result[row][col] = String(cell.raw)
}
}
}
return result;
}, [rows, cols, sheetName]);
return result
}, [rows, cols, sheetName])
// Экспорт в JSON для бэкенда
const exportToJSON = useCallback((): string => {
if (!workbookRef.current) return '{}';
return JSON.stringify(workbookRef.current.toJSON());
}, []);
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 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 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);
if (!workbookRef.current) return
setIsCalculating(true)
try {
workbookRef.current.recalc();
syncFromEngine();
workbookRef.current.recalc()
syncFromEngine()
} catch (error) {
console.error('Error recalculating:', error);
console.error('Error recalculating:', error)
} finally {
setIsCalculating(false);
setIsCalculating(false)
}
}, [syncFromEngine]);
}, [syncFromEngine])
// Получение информации о зависимостях ячейки
const getCellDependencies = useCallback((row: number, col: number) => {
if (!workbookRef.current) return { precedents: [], dependents: [] };
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]);
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 };
if (!workbookRef.current) return { sheets: 0, cells: 0, formulas: 0 }
let totalCells = 0
let totalFormulas = 0
let totalCells = 0;
let totalFormulas = 0;
for (const sheet of workbookRef.current.sheets.values()) {
totalCells += sheet.cells.size;
totalCells += sheet.cells.size
for (const cell of sheet.cells.values()) {
if (typeof cell.raw === 'string' && cell.raw.startsWith('=')) {
totalFormulas++;
totalFormulas++
}
}
}
return {
sheets: workbookRef.current.sheets.size,
cells: totalCells,
formulas: totalFormulas
};
}, []);
formulas: totalFormulas,
}
}, [])
return {
cells,
@@ -296,6 +331,6 @@ export function useSpreadsheetEngine({
getCellDependencies,
getWorkbookStats,
isCalculating,
workbook: workbookRef.current
};
}
workbook: workbookRef.current,
}
}

View File

@@ -1,548 +0,0 @@
// Регулярные выражения для ссылок
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.
const RANGE_RE = /\b[A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g; // A1:B2, D5:D7, etc.
const QUALIFIED_RANGE_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g; // SheetName!A1:B2
// Типы для значений ячеек
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: any[]) => {
const flatArgs = args.flat();
return flatArgs.reduce((sum, val) => sum + (typeof val === 'number' ? val : 0), 0);
},
AVERAGE: (...args: any[]) => {
const flatArgs = args.flat();
const validArgs = flatArgs.filter(val => typeof val === 'number');
return validArgs.length > 0 ? validArgs.reduce((sum, val) => sum + val, 0) / validArgs.length : 0;
},
COUNT: (...args: any[]) => {
const flatArgs = args.flat();
return flatArgs.filter(val => typeof val === 'number').length;
},
MAX: (...args: any[]) => {
const flatArgs = args.flat();
const validArgs = flatArgs.filter(val => typeof val === 'number');
return validArgs.length > 0 ? Math.max(...validArgs) : 0;
},
MIN: (...args: any[]) => {
const flatArgs = args.flat();
const validArgs = flatArgs.filter(val => typeof val === 'number');
return validArgs.length > 0 ? Math.min(...validArgs) : 0;
},
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;
}
// Функция для развертывания диапазона в массив значений
private expandRange(range: string, sheetName?: string): number[] {
const parts = range.split(':');
if (parts.length !== 2) return [];
const startCell = parts[0];
const endCell = parts[1];
const startCol = startCell.match(/[A-Z]+/)?.[0] || '';
const startRow = parseInt(startCell.match(/[0-9]+/)?.[0] || '0');
const endCol = endCell.match(/[A-Z]+/)?.[0] || '';
const endRow = parseInt(endCell.match(/[0-9]+/)?.[0] || '0');
const startColIndex = startCol.charCodeAt(0) - 65;
const endColIndex = endCol.charCodeAt(0) - 65;
const values: number[] = [];
for (let row = startRow; row <= endRow; row++) {
for (let col = startColIndex; col <= endColIndex; col++) {
const cellName = String.fromCharCode(65 + col) + row;
const qualifiedName = sheetName ? `${sheetName}!${cellName}` : `${this.sheet.name}!${cellName}`;
const cellValue = this.sheet.workbook.getCell(qualifiedName).evaluate();
const numValue = typeof cellValue === 'number' ? cellValue : parseFloat(String(cellValue)) || 0;
values.push(numValue);
}
}
return values;
}
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_RANGE_RE, (match) => {
const [sheetName, range] = match.split('!');
const values = this.expandRange(range, sheetName);
return `[${values.join(',')}]`;
});
code = code.replace(RANGE_RE, (match) => {
const values = this.expandRange(match);
return `[${values.join(',')}]`;
});
// Заменяем квалифицированные ссылки на ячейки
code = code.replace(QUALIFIED_REF_RE, (match) => {
try {
const [sheetName] = match.split('!');
const sheet = this.sheet.workbook.getSheet(sheetName);
if (!sheet) {
return '"#REF!"';
}
const cellValue = this.sheet.workbook.getCell(match).evaluate();
return typeof cellValue === 'string' ? `"${cellValue}"` : String(cellValue ?? 0);
} catch (error) {
return '"#REF!"';
}
});
// Заменяем локальные ссылки на ячейки
code = code.replace(LOCAL_REF_RE, (match) => {
// Избегаем замены функций
if (this.isFunction(match)) {
return match;
}
try {
const qualifiedRef = `${this.sheet.name}!${match}`;
const cellValue = this.sheet.workbook.getCell(qualifiedRef).evaluate();
return typeof cellValue === 'string' ? `"${cellValue}"` : String(cellValue ?? 0);
} catch (error) {
return '"#REF!"';
}
});
// Заменяем функции Excel
const functions = this.sheet.workbook.getFunctions();
for (const [funcName] 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,88 @@
export type CellValue = string | number | boolean | null | undefined
export interface ExcelFunction {
(...args: any[]): CellValue
}
// Волатильные функции - пересчитываются при любом изменении
export const VOLATILE_FUNCTIONS = new Set([
'RAND',
'RANDBETWEEN',
'NOW',
'TODAY',
])
export const DEFAULT_FUNCTIONS: Record<string, ExcelFunction> = {
RAND: () => Math.random(),
RANDBETWEEN: (bottom: number, top: number) =>
Math.floor(Math.random() * (top - bottom + 1)) + bottom,
SUM: (...args: any[]) => {
const flatArgs = args.flat()
return flatArgs.reduce(
(sum, val) => sum + (typeof val === 'number' ? val : 0),
0,
)
},
AVERAGE: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter((val) => typeof val === 'number')
return validArgs.length > 0
? validArgs.reduce((sum, val) => sum + val, 0) / validArgs.length
: 0
},
COUNT: (...args: any[]) => {
const flatArgs = args.flat()
return flatArgs.filter((val) => typeof val === 'number').length
},
MAX: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter((val) => typeof val === 'number')
return validArgs.length > 0 ? Math.max(...validArgs) : 0
},
MIN: (...args: any[]) => {
const flatArgs = args.flat()
const validArgs = flatArgs.filter((val) => typeof val === 'number')
return validArgs.length > 0 ? Math.min(...validArgs) : 0
},
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
},
}

View File

@@ -0,0 +1,478 @@
import { DEFAULT_FUNCTIONS, VOLATILE_FUNCTIONS } from './functions'
// Регулярные выражения для ссылок
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.
const RANGE_RE = /\b[A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g // A1:B2, D5:D7, etc.
const QUALIFIED_RANGE_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g // SheetName!A1:B2
// Типы для значений ячеек
export type CellValue = string | number | boolean | null | undefined
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_RANGE_RE, (match) => {
const [sheetName, range] = match.split('!')
const values = this.expandRange(range, sheetName)
return `[${values.join(',')}]`
})
code = code.replace(RANGE_RE, (match) => {
const values = this.expandRange(match)
return `[${values.join(',')}]`
})
// Заменяем квалифицированные ссылки на ячейки
code = code.replace(QUALIFIED_REF_RE, (match) => {
try {
const [sheetName] = match.split('!')
const sheet = this.sheet.workbook.getSheet(sheetName)
if (!sheet) {
return '"#REF!"'
}
const cellValue = this.sheet.workbook.getCell(match).evaluate()
return typeof cellValue === 'string'
? `"${cellValue}"`
: String(cellValue ?? 0)
} catch (error) {
return '"#REF!"'
}
})
// Заменяем локальные ссылки на ячейки
code = code.replace(LOCAL_REF_RE, (match) => {
// Избегаем замены функций
if (this.isFunction(match)) {
return match
}
try {
const qualifiedRef = `${this.sheet.name}!${match}`
const cellValue = this.sheet.workbook.getCell(qualifiedRef).evaluate()
return typeof cellValue === 'string'
? `"${cellValue}"`
: String(cellValue ?? 0)
} catch (error) {
return '"#REF!"'
}
})
// Заменяем функции Excel
const functions = this.sheet.workbook.getFunctions()
for (const [funcName] 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}`)
}
}
// Функция для развертывания диапазона в массив значений
private expandRange(range: string, sheetName?: string): number[] {
const parts = range.split(':')
if (parts.length !== 2) return []
const startCell = parts[0]
const endCell = parts[1]
const startCol = startCell.match(/[A-Z]+/)?.[0] || ''
const startRow = parseInt(startCell.match(/[0-9]+/)?.[0] || '0')
const endCol = endCell.match(/[A-Z]+/)?.[0] || ''
const endRow = parseInt(endCell.match(/[0-9]+/)?.[0] || '0')
const startColIndex = startCol.charCodeAt(0) - 65
const endColIndex = endCol.charCodeAt(0) - 65
const values: number[] = []
for (let row = startRow; row <= endRow; row++) {
for (let col = startColIndex; col <= endColIndex; col++) {
const cellName = String.fromCharCode(65 + col) + row
const qualifiedName = sheetName
? `${sheetName}!${cellName}`
: `${this.sheet.name}!${cellName}`
const cellValue = this.sheet.workbook.getCell(qualifiedName).evaluate()
const numValue =
typeof cellValue === 'number'
? cellValue
: parseFloat(String(cellValue)) || 0
values.push(numValue)
}
}
return values
}
}
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

@@ -1,191 +0,0 @@
import { CellUtils, Workbook } from './spreadsheet-engine';
// Пример использования движка электронной таблицы
export function createExampleWorkbook(): Workbook {
const wb = new Workbook();
// Создаём листы
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'); // Строковая операция с локальной ссылкой
// Пример с квалифицированными ссылками (если есть несколько листов)
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 = 50, maxCols: number = 20): 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'));
}

View File

@@ -1,57 +0,0 @@
export const exampleTemplateData = {
A1: 'Отчет о продажах',
A2: 'Период: Январь 2024',
A3: '',
A4: 'Товар',
B4: 'Количество',
C4: 'Цена',
D4: 'Сумма',
A5: 'Товар A',
B5: 100,
C5: 1500,
D5: 150000,
A6: 'Товар B',
B6: 50,
C6: 2000,
D6: 100000,
A7: 'Товар C',
B7: 75,
C7: 1200,
D7: 90000,
A8: '',
A9: 'Итого:',
D9: 340000,
A11: 'НДС (20%):',
D11: 68000,
A12: 'Всего к оплате:',
D12: 408000,
A14: 'Скидка из расчетов:',
D14: '',
A15: 'Итого со скидкой:',
D15: ''
};
export const exampleCalculationsData = {
A1: 'Расчеты',
A2: 'Скидка:',
B2: '=Report!D9*0.05',
A4: 'Дополнительные расчеты:',
A5: 'Средняя цена:',
B5: '=AVERAGE(Report!C5:C7)',
A6: 'Максимальная сумма:',
B6: '=MAX(Report!D5:D7)',
A7: 'Минимальная сумма:',
B7: '=MIN(Report!D5:D7)',
};