Initial QOffice implementation
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user