Files
QOffice/src/features/qexcell/QExcell.tsx
T
Курнат Андрей 4822ba84a0 Preserve QExcell text wrapping
2026-06-01 07:34:23 +03:00

967 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 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<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" | "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 (
<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>
<button
className={`icon-button format-toggle ${selectedCellFormat.strike ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSelectedFormat("strike")}
title="Зачеркнуть"
aria-label="Зачеркнуть"
>
<Strikethrough size={16} />
</button>
{horizontalAlignOptions.map((option) => {
const Icon = option.icon;
const isActive = selectedCellFormat.horizontalAlign === option.value;
return (
<button
key={option.value}
className={`icon-button format-toggle ${isActive ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateCellFormat(selectedCell, { horizontalAlign: isActive ? undefined : option.value })}
title={option.label}
aria-label={option.label}
>
<Icon size={16} />
</button>
);
})}
<button
className={`icon-button format-toggle ${selectedCellFormat.wrapText ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSelectedFormat("wrapText")}
title="Перенос текста"
aria-label="Перенос текста"
>
<WrapText size={16} />
</button>
<select
value={selectedCellFormat.fontFamily ?? ""}
onChange={(event) => updateCellFormat(selectedCell, { fontFamily: event.target.value || undefined })}
aria-label="Семейство шрифта"
title="Семейство шрифта"
>
<option value="">Calibri</option>
{fontFamilyOptions.map((fontFamily) => (
<option key={fontFamily} value={fontFamily}>
{fontFamily}
</option>
))}
</select>
<select
value={selectedCellFormat.fontSize ?? ""}
onChange={(event) =>
updateCellFormat(selectedCell, { fontSize: event.target.value ? Number(event.target.value) : undefined })
}
aria-label="Размер шрифта"
title="Размер шрифта"
>
<option value="">Размер</option>
{fontSizeOptions.map((fontSize) => (
<option key={fontSize} value={fontSize}>
{fontSize}
</option>
))}
</select>
<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 className="fill-swatches" aria-label="Цвет текста">
<Type size={16} aria-hidden="true" />
{textColorSwatches.map((swatch) => (
<button
key={swatch.label}
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${selectedCellFormat.textColor === swatch.color || (!selectedCellFormat.textColor && !swatch.color) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateCellFormat(selectedCell, { textColor: 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" : ""} ${cellFormat?.wrapText ? "is-wrapped" : ""}`.trim()}
colSpan={merge?.columnSpan}
rowSpan={merge?.rowSpan}
>
{cellFormat?.wrapText ? (
<textarea
value={selectedCell === id ? raw : display}
aria-label={`Ячейка ${id}`}
title={hyperlink || undefined}
style={cellInputStyle(cellFormat)}
onFocus={() => setSelectedCell(id)}
onChange={(event) => updateCell(id, event.target.value)}
/>
) : (
<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)
};
}