рефакторинг
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
88
src/lib/spreadsheet-engine/functions.ts
Normal file
88
src/lib/spreadsheet-engine/functions.ts
Normal 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
|
||||
},
|
||||
}
|
||||
478
src/lib/spreadsheet-engine/spreadsheet-engine.ts
Normal file
478
src/lib/spreadsheet-engine/spreadsheet-engine.ts
Normal 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 }
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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)',
|
||||
};
|
||||
Reference in New Issue
Block a user