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