import { useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import { AlignCenter, AlignLeft, AlignRight, BarChart3, Bold, Download, Eraser, FileText, FileUp, FunctionSquare, Italic, Link, LineChart, PaintBucket, PieChart, Plus, Save, Sigma, Strikethrough, Trash2, Type, Underline, WrapText } 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 textColorSwatches = [ { color: "", label: "Авто цвет текста" }, { color: "202124", label: "Черный текст" }, { color: "0F5FAE", label: "Синий текст" }, { color: "0F766E", label: "Зеленый текст" }, { color: "C2410C", label: "Красный текст" }, { color: "7C3AED", 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: "Круговая" } ]; const horizontalAlignOptions = [ { value: "left", label: "По левому краю", icon: AlignLeft }, { value: "center", label: "По центру", icon: AlignCenter }, { value: "right", label: "По правому краю", icon: AlignRight } ] as const; const fontFamilyOptions = ["Aptos", "Arial", "Courier New", "Times New Roman"]; const fontSizeOptions = [10, 11, 12, 14, 16, 18, 20, 24]; 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 textColor = normalizedFillColor(format.textColor); const fillColor = normalizedFillColor(format.fillColor); const horizontalAlign = normalizedHorizontalAlign(format.horizontalAlign); const numberFormat = format.numberFormat?.trim(); const fontSize = normalizedFontSize(format.fontSize); const fontFamily = normalizedFontFamily(format.fontFamily); const normalized: CellFormat = { ...(format.bold ? { bold: true } : {}), ...(format.italics ? { italics: true } : {}), ...(format.underline ? { underline: true } : {}), ...(format.strike ? { strike: true } : {}), ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}), ...(textColor ? { textColor } : {}), ...(fillColor ? { fillColor } : {}), ...(horizontalAlign ? { horizontalAlign } : {}), ...(format.wrapText ? { wrapText: true } : {}), ...(numberFormat ? { numberFormat } : {}) }; return Object.keys(normalized).length > 0 ? normalized : undefined; } function cellInputStyle(format?: CellFormat): CSSProperties { const textDecoration = [format?.underline ? "underline" : "", format?.strike ? "line-through" : ""].filter(Boolean).join(" "); return { ...(format?.bold ? { fontWeight: 700 } : {}), ...(format?.italics ? { fontStyle: "italic" } : {}), ...(textDecoration ? { textDecoration } : {}), ...(format?.fontSize ? { fontSize: `${format.fontSize}pt` } : {}), ...(format?.fontFamily ? { fontFamily: format.fontFamily } : {}), ...(format?.textColor ? { color: `#${format.textColor}` } : {}), ...(format?.fillColor ? { backgroundColor: `#${format.fillColor}` } : {}), ...(format?.horizontalAlign ? { textAlign: format.horizontalAlign } : {}), ...(format?.wrapText ? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" } : {}) }; } function normalizedHorizontalAlign(value?: string) { return value === "left" || value === "center" || value === "right" ? value : ""; } function normalizedFontSize(value?: number) { if (!Number.isFinite(value) || !value || value <= 0) { return undefined; } const normalized = Math.round(value * 2) / 2; return normalized === 11 ? undefined : normalized; } function normalizedFontFamily(value?: string) { const normalized = value?.trim().replace(/["<>]/g, "").slice(0, 80) ?? ""; return normalized && normalized.toLowerCase() !== "calibri" ? normalized : ""; } 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" | "strike" | "wrapText") { 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 })} />
{horizontalAlignOptions.map((option) => { const Icon = option.icon; const isActive = selectedCellFormat.horizontalAlign === option.value; return ( ); })}
{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} {cellFormat?.wrapText ? (