import { useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import { BarChart3, Bold, Download, Eraser, FileText, FileUp, FunctionSquare, Italic, Link, LineChart, PaintBucket, PieChart, Plus, Save, Sigma, Trash2, Underline } from "lucide-react"; import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types"; import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet"; import { downloadBlobFile, downloadTextFile } from "../../utils/download"; import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice"; import { replaceFileExtension } from "../../io/fileHelpers"; import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles"; import { exportPdfOrPrint } from "../../io/pdfExport"; import { useQOfficeCommand } from "../../hooks/useQOfficeCommand"; interface QExcellProps { workbook: Workbook; onChange: (workbook: Workbook) => void; } const extraEditableRows = 1; const extraEditableColumns = 1; const emptyHyperlinks: Record = {}; const emptyCellFormats: Record = {}; const emptyCharts: SheetChart[] = []; const fillSwatches = [ { color: "", label: "Без заливки" }, { color: "FFF2CC", label: "Светло-желтая заливка" }, { color: "D9EAD3", label: "Светло-зеленая заливка" }, { color: "DDEBF7", label: "Светло-синяя заливка" }, { color: "FCE4D6", label: "Светло-коралловая заливка" }, { color: "EADCF8", label: "Светло-фиолетовая заливка" } ]; const numberFormatOptions = [ { value: "", label: "Обычный" }, { value: "0.00", label: "Число" }, { value: "# ##0.00 ₽", label: "Деньги" }, { value: "0%", label: "Процент" }, { value: "dd.mm.yyyy", label: "Дата" }, { value: "dd.mm.yyyy hh:mm", label: "Дата и время" } ]; const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [ { value: "bar", label: "Столбчатая" }, { value: "line", label: "Линейная" }, { value: "pie", label: "Круговая" } ]; function normalizedLinkInput(value: string | null) { const raw = value?.trim(); if (!raw) { return ""; } const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`; try { const url = new URL(candidate); return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : ""; } catch { window.alert("Введите корректный адрес ссылки."); return ""; } } function normalizedFillColor(value?: string) { const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? ""; return /^[0-9A-F]{6}$/.test(raw) ? raw : ""; } function normalizedCellFormat(format: CellFormat): CellFormat | undefined { const fillColor = normalizedFillColor(format.fillColor); const numberFormat = format.numberFormat?.trim(); const normalized: CellFormat = { ...(format.bold ? { bold: true } : {}), ...(format.italics ? { italics: true } : {}), ...(format.underline ? { underline: true } : {}), ...(fillColor ? { fillColor } : {}), ...(numberFormat ? { numberFormat } : {}) }; return Object.keys(normalized).length > 0 ? normalized : undefined; } function cellInputStyle(format?: CellFormat): CSSProperties { return { ...(format?.bold ? { fontWeight: 700 } : {}), ...(format?.italics ? { fontStyle: "italic" } : {}), ...(format?.underline ? { textDecoration: "underline" } : {}), ...(format?.fillColor ? { backgroundColor: `#${format.fillColor}` } : {}) }; } function normalizedChartRange(value: string) { const raw = value.trim().replace(/\$/g, "").toUpperCase(); const [start, end = start] = raw.split(":"); if (!/^[A-Z]+[1-9][0-9]*$/.test(start) || !/^[A-Z]+[1-9][0-9]*$/.test(end)) { return ""; } return `${start}:${end}`; } function chartTypeIcon(type: SheetChartType) { if (type === "line") { return LineChart; } if (type === "pie") { return PieChart; } return BarChart3; } function chartTypeLabel(type: SheetChartType) { return chartTypeOptions.find((option) => option.value === type)?.label ?? "Столбчатая"; } export function QExcell({ workbook, onChange }: QExcellProps) { const fileInputRef = useRef(null); const [selectedCell, setSelectedCell] = useState("A1"); const [selectedChartId, setSelectedChartId] = useState(""); const [chartTitle, setChartTitle] = useState("Итого по продуктам"); const [chartType, setChartType] = useState("bar"); const [chartLabelRange, setChartLabelRange] = useState("A2:A4"); const [chartValueRange, setChartValueRange] = useState("E2:E4"); const activeSheet = workbook.sheets.find((sheet) => sheet.id === workbook.activeSheet) ?? workbook.sheets[0] ?? { id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] }; const activeMergedCells = activeSheet.mergedCells ?? []; const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks; const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats; const activeCharts = activeSheet.charts ?? emptyCharts; const selectedRawValue = activeSheet.cells[selectedCell] ?? ""; const selectedHyperlink = activeHyperlinks[selectedCell] ?? ""; const selectedCellFormat = activeCellFormats[selectedCell] ?? {}; const selectedFormattedValue = formatCellValueForDisplay(evaluateCell(selectedCell, activeSheet.cells), selectedCellFormat); const boundsCells = useMemo( () => [...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce>( (cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || "format" }), { ...activeSheet.cells } ), [activeCellFormats, activeHyperlinks, activeSheet.cells] ); const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]); const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]); const visibleColumnLabels = useMemo( () => columnLabelsForCount(sheetBounds.columnCount + extraEditableColumns), [sheetBounds.columnCount] ); const totals = useMemo(() => { return summarizeColumn(activeSheet.cells, 4); }, [activeSheet.cells]); function updateCell(id: string, value: string) { onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => sheet.id === activeSheet.id ? { ...sheet, cells: { ...sheet.cells, [id]: value } } : sheet ) }); } function updateHyperlink(id: string, href: string) { const normalizedId = id.toUpperCase(); const nextHyperlinks = { ...activeHyperlinks }; if (href) { nextHyperlinks[normalizedId] = href; } else { delete nextHyperlinks[normalizedId]; } onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hyperlinks: nextHyperlinks } : sheet)) }); } function updateCellFormat(id: string, formatPatch: CellFormat) { const normalizedId = id.toUpperCase(); const nextFormats = { ...activeCellFormats }; const nextFormat = normalizedCellFormat({ ...(nextFormats[normalizedId] ?? {}), ...formatPatch }); if (nextFormat) { nextFormats[normalizedId] = nextFormat; } else { delete nextFormats[normalizedId]; } onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, cellFormats: nextFormats } : sheet)) }); } function toggleSelectedFormat(key: "bold" | "italics" | "underline") { updateCellFormat(selectedCell, { [key]: !selectedCellFormat[key] }); } function insertLink() { const href = normalizedLinkInput(window.prompt("Введите адрес ссылки", selectedHyperlink)); if (!href) { return; } const nextCells = selectedRawValue ? activeSheet.cells : { ...activeSheet.cells, [selectedCell]: href }; onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: { ...activeHyperlinks, [selectedCell]: href } } : sheet ) }); } function clearSelectedCell() { const nextCells = { ...activeSheet.cells }; const nextHyperlinks = { ...activeHyperlinks }; const nextFormats = { ...activeCellFormats }; delete nextCells[selectedCell]; delete nextHyperlinks[selectedCell]; delete nextFormats[selectedCell]; onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, cellFormats: nextFormats } : sheet ) }); } function updateActiveSheetName(name: string) { onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, name } : sheet)) }); } function selectChart(chart: SheetChart) { setSelectedChartId(chart.id); setChartTitle(chart.title); setChartType(chart.type); setChartLabelRange(chart.labelRange); setChartValueRange(chart.valueRange); } function resetChartForm() { setSelectedChartId(""); setChartTitle("Итого по продуктам"); setChartType("bar"); setChartLabelRange("A2:A4"); setChartValueRange("E2:E4"); } function upsertChart() { const labelRange = normalizedChartRange(chartLabelRange); const valueRange = normalizedChartRange(chartValueRange); if (!labelRange || !valueRange) { window.alert("Введите диапазоны диаграммы в формате A2:A4."); return; } const nextChart: SheetChart = { id: selectedChartId || `chart-${Date.now()}`, type: chartType, title: chartTitle.trim() || chartTypeLabel(chartType), labelRange, valueRange }; const nextCharts = selectedChartId ? activeCharts.map((chart) => (chart.id === selectedChartId ? nextChart : chart)) : [...activeCharts, nextChart]; onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, charts: nextCharts } : sheet)) }); setSelectedChartId(nextChart.id); } function deleteChart(id: string) { const nextCharts = activeCharts.filter((chart) => chart.id !== id); onChange({ ...workbook, sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, charts: nextCharts } : sheet)) }); if (selectedChartId === id) { resetChartForm(); } } function exportCsv() { const exportBounds = usedSheetBounds(activeSheet.cells, activeMergedCells); const exportRows = Array.from({ length: exportBounds.rowCount }, (_, index) => index); const exportColumnLabels = columnLabelsForCount(exportBounds.columnCount); const csv = exportRows .map((row) => exportColumnLabels .map((_, column) => { const value = activeSheet.cells[cellId(column, row)] ?? ""; return `"${value.replace(/"/g, '""')}"`; }) .join(",") ) .join("\n"); downloadTextFile(workbook.title.replace(/\.qxlsx$/i, ".csv"), csv, "text/csv"); } async function openXlsx(file: File | undefined) { if (!file) { return; } try { const imported = await importXlsxFile(file); onChange({ ...workbook, title: imported.title, activeSheet: imported.sheets[0]?.id ?? "sheet-1", sheets: imported.sheets.length > 0 ? imported.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] }] }); } catch (error) { window.alert(error instanceof Error ? error.message : "Не удалось открыть XLSX."); } finally { if (fileInputRef.current) { fileInputRef.current.value = ""; } } } async function saveXlsx() { try { const fileName = replaceFileExtension(workbook.title, ".xlsx"); const blob = await exportXlsxBlob(workbook); if (isDesktopOfficeBridgeAvailable()) { await saveDesktopOfficeFile("xlsx", fileName, blob); return; } downloadBlobFile(fileName, blob); } catch (error) { window.alert(error instanceof Error ? error.message : "Не удалось сохранить XLSX."); } } async function chooseXlsx() { if (isDesktopOfficeBridgeAvailable()) { const file = await openDesktopOfficeFile("xlsx"); await openXlsx(file ?? undefined); return; } fileInputRef.current?.click(); } async function exportPdf() { try { await exportPdfOrPrint(workbook.title); } catch (error) { window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF."); } } useQOfficeCommand({ open: chooseXlsx, save: saveXlsx, "export-pdf": exportPdf, print: () => window.print() }); return (
void openXlsx(event.target.files?.[0])} aria-label="Открыть XLSX" /> onChange({ ...workbook, title: event.target.value })} />
{selectedCell} updateCell(selectedCell, event.target.value)} placeholder="Введите значение или формулу, например =SUM(B2:D2)" />
))} {rows.map((row) => ( {visibleColumnLabels.map((_, column) => { const id = cellId(column, row); const merge = mergedCellInfo(id, activeMergedCells); if (merge && !merge.isOrigin) { return null; } const raw = activeSheet.cells[id] ?? ""; const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw; const hyperlink = activeHyperlinks[id] ?? ""; const cellFormat = activeCellFormats[id]; const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat); return ( ); })} ))}
{visibleColumnLabels.map((column) => ( {column}
{row + 1} setSelectedCell(id)} onChange={(event) => updateCell(id, event.target.value)} />
{activeCharts.length > 0 ? (
{activeCharts.map((chart) => ( selectChart(chart)} onDelete={() => deleteChart(chart.id)} /> ))}
) : null}
); } function SheetChartPreview({ chart, cells, selected, onSelect, onDelete }: { chart: SheetChart; cells: Record; selected: boolean; onSelect: () => void; onDelete: () => void; }) { const Icon = chartTypeIcon(chart.type); const points = chartDataPoints(chart, cells); return (
{chart.title}

{chartTypeLabel(chart.type)} · {chart.labelRange} / {chart.valueRange}

); } function ChartSvg({ type, points }: { type: SheetChartType; points: Array<{ label: string; value: number }> }) { if (points.length === 0) { return ( Нет данных ); } if (type === "pie") { return ; } if (type === "line") { return ; } return ; } function BarChartSvg({ points }: { points: Array<{ label: string; value: number }> }) { const max = Math.max(1, ...points.map((point) => Math.abs(point.value))); const width = 320 / points.length; return ( {points.map((point, index) => { const barHeight = Math.round((Math.abs(point.value) / max) * 112); const x = 32 + index * width; return ( {point.label.slice(0, 10)} ); })} ); } function LineChartSvg({ points }: { points: Array<{ label: string; value: number }> }) { const max = Math.max(1, ...points.map((point) => Math.abs(point.value))); const step = points.length > 1 ? 300 / (points.length - 1) : 0; const coordinates = points.map((point, index) => { const x = points.length > 1 ? 30 + index * step : 180; const y = 142 - (Math.abs(point.value) / max) * 112; return { x, y, label: point.label }; }); const path = coordinates.map((point) => `${point.x},${point.y}`).join(" "); return ( {coordinates.map((point, index) => ( {point.label.slice(0, 10)} ))} ); } function PieChartSvg({ points }: { points: Array<{ label: string; value: number }> }) { const total = points.reduce((sum, point) => sum + Math.max(0, point.value), 0) || 1; let startAngle = -90; return ( {points.map((point, index) => { const value = Math.max(0, point.value); const angle = (value / total) * 360; const path = pieSlicePath(95, 90, 58, startAngle, startAngle + angle); startAngle += angle; return ; })} {points.slice(0, 5).map((point, index) => ( {point.label.slice(0, 18)} ))} ); } function pieSlicePath(cx: number, cy: number, radius: number, startAngle: number, endAngle: number) { const start = polarToCartesian(cx, cy, radius, endAngle); const end = polarToCartesian(cx, cy, radius, startAngle); const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"; return `M ${cx} ${cy} L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`; } function polarToCartesian(cx: number, cy: number, radius: number, angle: number) { const radians = (angle * Math.PI) / 180; return { x: cx + radius * Math.cos(radians), y: cy + radius * Math.sin(radians) }; }