823 lines
30 KiB
TypeScript
823 lines
30 KiB
TypeScript
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<string, string> = {};
|
||
const emptyCellFormats: Record<string, CellFormat> = {};
|
||
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<HTMLInputElement>(null);
|
||
const [selectedCell, setSelectedCell] = useState("A1");
|
||
const [selectedChartId, setSelectedChartId] = useState("");
|
||
const [chartTitle, setChartTitle] = useState("Итого по продуктам");
|
||
const [chartType, setChartType] = useState<SheetChartType>("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<Record<string, string>>(
|
||
(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 (
|
||
<div className="editor-layout sheet-layout">
|
||
<section className="spreadsheet-stage" aria-label="Редактор QExcell">
|
||
<div className="module-toolbar">
|
||
<input
|
||
ref={fileInputRef}
|
||
className="hidden-file-input"
|
||
type="file"
|
||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
onChange={(event) => void openXlsx(event.target.files?.[0])}
|
||
aria-label="Открыть XLSX"
|
||
/>
|
||
<input
|
||
className="file-title-input"
|
||
value={workbook.title}
|
||
aria-label="Название книги"
|
||
onChange={(event) => onChange({ ...workbook, title: event.target.value })}
|
||
/>
|
||
<button className="compact-command" type="button" onClick={() => updateCell(selectedCell, "=SUM(B2:D2)")}>
|
||
<Sigma size={16} />
|
||
Сумма
|
||
</button>
|
||
<button className="compact-command" type="button" onClick={() => updateCell(selectedCell, "=AVG(B2:D2)")}>
|
||
<FunctionSquare size={16} />
|
||
Среднее
|
||
</button>
|
||
<button className="compact-command" type="button" onClick={clearSelectedCell}>
|
||
<Eraser size={16} />
|
||
Очистить
|
||
</button>
|
||
<button className="compact-command" type="button" onClick={insertLink}>
|
||
<Link size={16} />
|
||
Ссылка
|
||
</button>
|
||
<div className="cell-format-tools" aria-label="Формат ячейки">
|
||
<button
|
||
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
|
||
type="button"
|
||
onClick={() => toggleSelectedFormat("bold")}
|
||
title="Полужирный"
|
||
aria-label="Полужирный"
|
||
>
|
||
<Bold size={16} />
|
||
</button>
|
||
<button
|
||
className={`icon-button format-toggle ${selectedCellFormat.italics ? "is-active" : ""}`.trim()}
|
||
type="button"
|
||
onClick={() => toggleSelectedFormat("italics")}
|
||
title="Курсив"
|
||
aria-label="Курсив"
|
||
>
|
||
<Italic size={16} />
|
||
</button>
|
||
<button
|
||
className={`icon-button format-toggle ${selectedCellFormat.underline ? "is-active" : ""}`.trim()}
|
||
type="button"
|
||
onClick={() => toggleSelectedFormat("underline")}
|
||
title="Подчеркнуть"
|
||
aria-label="Подчеркнуть"
|
||
>
|
||
<Underline size={16} />
|
||
</button>
|
||
<div className="fill-swatches" aria-label="Заливка">
|
||
<PaintBucket size={16} aria-hidden="true" />
|
||
{fillSwatches.map((swatch) => (
|
||
<button
|
||
key={swatch.label}
|
||
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${selectedCellFormat.fillColor === swatch.color || (!selectedCellFormat.fillColor && !swatch.color) ? "is-active" : ""}`.trim()}
|
||
type="button"
|
||
onClick={() => updateCellFormat(selectedCell, { fillColor: swatch.color })}
|
||
title={swatch.label}
|
||
aria-label={swatch.label}
|
||
style={swatch.color ? { backgroundColor: `#${swatch.color}` } : undefined}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button className="compact-command" type="button" onClick={() => void chooseXlsx()}>
|
||
<FileUp size={16} />
|
||
Открыть XLSX
|
||
</button>
|
||
<button className="compact-command" type="button" onClick={() => void saveXlsx()}>
|
||
<Save size={16} />
|
||
Сохранить XLSX
|
||
</button>
|
||
<button className="compact-command" type="button" onClick={exportCsv}>
|
||
<Download size={16} />
|
||
Экспорт CSV
|
||
</button>
|
||
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
|
||
<FileText size={16} />
|
||
Экспорт PDF
|
||
</button>
|
||
</div>
|
||
|
||
<div className="formula-bar">
|
||
<span>{selectedCell}</span>
|
||
<input
|
||
value={selectedRawValue}
|
||
aria-label="Строка формул"
|
||
onChange={(event) => updateCell(selectedCell, event.target.value)}
|
||
placeholder="Введите значение или формулу, например =SUM(B2:D2)"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid-wrap">
|
||
<table className="sheet-grid">
|
||
<thead>
|
||
<tr>
|
||
<th aria-label="Номера строк" />
|
||
{visibleColumnLabels.map((column) => (
|
||
<th key={column}>{column}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row) => (
|
||
<tr key={row}>
|
||
<th>{row + 1}</th>
|
||
{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 (
|
||
<td
|
||
key={id}
|
||
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""}`.trim()}
|
||
colSpan={merge?.columnSpan}
|
||
rowSpan={merge?.rowSpan}
|
||
>
|
||
<input
|
||
value={selectedCell === id ? raw : display}
|
||
aria-label={`Ячейка ${id}`}
|
||
title={hyperlink || undefined}
|
||
style={cellInputStyle(cellFormat)}
|
||
onFocus={() => setSelectedCell(id)}
|
||
onChange={(event) => updateCell(id, event.target.value)}
|
||
/>
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{activeCharts.length > 0 ? (
|
||
<div className="chart-board" aria-label="Диаграммы листа">
|
||
{activeCharts.map((chart) => (
|
||
<SheetChartPreview
|
||
key={chart.id}
|
||
chart={chart}
|
||
cells={activeSheet.cells}
|
||
selected={chart.id === selectedChartId}
|
||
onSelect={() => selectChart(chart)}
|
||
onDelete={() => deleteChart(chart.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<aside className="inspector" aria-label="Сводка книги">
|
||
<section>
|
||
<h3>Книга</h3>
|
||
<label>
|
||
Лист
|
||
<select value={workbook.activeSheet} onChange={(event) => onChange({ ...workbook, activeSheet: event.target.value })}>
|
||
{workbook.sheets.map((sheet) => (
|
||
<option key={sheet.id} value={sheet.id}>
|
||
{sheet.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button
|
||
className="wide-command"
|
||
type="button"
|
||
onClick={() => {
|
||
const id = `sheet-${workbook.sheets.length + 1}`;
|
||
onChange({
|
||
...workbook,
|
||
activeSheet: id,
|
||
sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }]
|
||
});
|
||
}}
|
||
>
|
||
<Plus size={16} />
|
||
Новый лист
|
||
</button>
|
||
<label>
|
||
Имя листа
|
||
<input value={activeSheet.name} onChange={(event) => updateActiveSheetName(event.target.value)} />
|
||
</label>
|
||
</section>
|
||
<section>
|
||
<h3>Выделение</h3>
|
||
<p className="stat-line">{selectedCell}</p>
|
||
<p className="muted">Значение: {selectedRawValue || "пусто"}</p>
|
||
<p className="muted">Расчет: {selectedFormattedValue || "пусто"}</p>
|
||
<label>
|
||
Формат числа
|
||
<select value={selectedCellFormat.numberFormat ?? ""} onChange={(event) => updateCellFormat(selectedCell, { numberFormat: event.target.value })}>
|
||
{numberFormatOptions.map((option) => (
|
||
<option key={option.label} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Ссылка
|
||
<input
|
||
value={selectedHyperlink}
|
||
placeholder="https://example.com"
|
||
onChange={(event) => updateHyperlink(selectedCell, event.target.value)}
|
||
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
|
||
/>
|
||
</label>
|
||
<button className="wide-command" type="button" onClick={insertLink}>
|
||
<Link size={16} />
|
||
Вставить ссылку
|
||
</button>
|
||
<p className="muted">Объединённых диапазонов: {activeMergedCells.length}</p>
|
||
</section>
|
||
<section>
|
||
<h3>Диаграммы</h3>
|
||
<label>
|
||
Заголовок
|
||
<input value={chartTitle} onChange={(event) => setChartTitle(event.target.value)} />
|
||
</label>
|
||
<label>
|
||
Тип
|
||
<select value={chartType} onChange={(event) => setChartType(event.target.value as SheetChartType)}>
|
||
{chartTypeOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Подписи
|
||
<input value={chartLabelRange} onChange={(event) => setChartLabelRange(event.target.value)} placeholder="A2:A4" />
|
||
</label>
|
||
<label>
|
||
Значения
|
||
<input value={chartValueRange} onChange={(event) => setChartValueRange(event.target.value)} placeholder="E2:E4" />
|
||
</label>
|
||
<button className="wide-command" type="button" onClick={upsertChart}>
|
||
<BarChart3 size={16} />
|
||
{selectedChartId ? "Сохранить диаграмму" : "Добавить диаграмму"}
|
||
</button>
|
||
{selectedChartId ? (
|
||
<button className="wide-command" type="button" onClick={resetChartForm}>
|
||
<Plus size={16} />
|
||
Новая диаграмма
|
||
</button>
|
||
) : null}
|
||
<p className="muted">Диаграмм на листе: {activeCharts.length}</p>
|
||
</section>
|
||
<section>
|
||
<h3>Сводка</h3>
|
||
<p className="big-number">{totals.toLocaleString("ru-RU")}</p>
|
||
<p className="muted">Сумма числовых значений в столбце E.</p>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SheetChartPreview({
|
||
chart,
|
||
cells,
|
||
selected,
|
||
onSelect,
|
||
onDelete
|
||
}: {
|
||
chart: SheetChart;
|
||
cells: Record<string, string>;
|
||
selected: boolean;
|
||
onSelect: () => void;
|
||
onDelete: () => void;
|
||
}) {
|
||
const Icon = chartTypeIcon(chart.type);
|
||
const points = chartDataPoints(chart, cells);
|
||
|
||
return (
|
||
<article className={`sheet-chart ${selected ? "is-selected" : ""}`.trim()} onClick={onSelect}>
|
||
<header>
|
||
<div>
|
||
<Icon size={18} />
|
||
<strong>{chart.title}</strong>
|
||
</div>
|
||
<button
|
||
className="icon-button"
|
||
type="button"
|
||
title="Удалить диаграмму"
|
||
aria-label={`Удалить диаграмму ${chart.title}`}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDelete();
|
||
}}
|
||
>
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</header>
|
||
<ChartSvg type={chart.type} points={points} />
|
||
<p className="muted">
|
||
{chartTypeLabel(chart.type)} · {chart.labelRange} / {chart.valueRange}
|
||
</p>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function ChartSvg({ type, points }: { type: SheetChartType; points: Array<{ label: string; value: number }> }) {
|
||
if (points.length === 0) {
|
||
return (
|
||
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Пустая диаграмма">
|
||
<text x="180" y="95" textAnchor="middle">
|
||
Нет данных
|
||
</text>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
if (type === "pie") {
|
||
return <PieChartSvg points={points} />;
|
||
}
|
||
|
||
if (type === "line") {
|
||
return <LineChartSvg points={points} />;
|
||
}
|
||
|
||
return <BarChartSvg points={points} />;
|
||
}
|
||
|
||
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 (
|
||
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Столбчатая диаграмма">
|
||
<line x1="28" y1="142" x2="346" y2="142" />
|
||
{points.map((point, index) => {
|
||
const barHeight = Math.round((Math.abs(point.value) / max) * 112);
|
||
const x = 32 + index * width;
|
||
return (
|
||
<g key={`${point.label}-${index}`}>
|
||
<rect x={x + 8} y={142 - barHeight} width={Math.max(16, width - 18)} height={barHeight} rx="4" />
|
||
<text x={x + width / 2} y="162" textAnchor="middle">
|
||
{point.label.slice(0, 10)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Линейная диаграмма">
|
||
<line x1="28" y1="142" x2="346" y2="142" />
|
||
<polyline points={path} />
|
||
{coordinates.map((point, index) => (
|
||
<g key={`${point.label}-${index}`}>
|
||
<circle cx={point.x} cy={point.y} r="4" />
|
||
<text x={point.x} y="162" textAnchor="middle">
|
||
{point.label.slice(0, 10)}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Круговая диаграмма">
|
||
{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 <path key={`${point.label}-${index}`} d={path} className={`pie-slice slice-${index % 6}`} />;
|
||
})}
|
||
{points.slice(0, 5).map((point, index) => (
|
||
<g key={`${point.label}-${index}`} className="pie-legend">
|
||
<rect x="190" y={42 + index * 20} width="10" height="10" className={`pie-slice slice-${index % 6}`} />
|
||
<text x="208" y={52 + index * 20}>
|
||
{point.label.slice(0, 18)}
|
||
</text>
|
||
</g>
|
||
))}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
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)
|
||
};
|
||
}
|