feat(editor): support radar chart and multiple series for other chart types (#10824)

This commit is contained in:
David Luzar
2026-02-26 16:13:15 +01:00
committed by GitHub
parent cae9d2bcbd
commit 60b275880d
29 changed files with 3102 additions and 1354 deletions
+15 -8
View File
@@ -425,6 +425,8 @@ import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
import { tryParseSpreadsheet } from "../charts";
import ConvertElementTypePopup, {
getConversionTypeFromElements,
convertElementTypePopupAtom,
@@ -3542,14 +3544,19 @@ class App extends React.Component<AppProps, AppState> {
}
// ------------------- Spreadsheet -------------------
if (data.spreadsheet && !isPlainPaste) {
this.setState({
pasteDialog: {
data: data.spreadsheet,
shown: true,
},
});
return;
if (!isPlainPaste && data.text) {
const result = tryParseSpreadsheet(data.text);
if (result.ok) {
this.setState({
openDialog: {
name: "charts",
data: result.data,
rawText: data.text,
},
});
return;
}
}
// ------------------- Images or SVG code -------------------
+4 -4
View File
@@ -556,13 +556,13 @@ const LayerUI = ({
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}
{appState.pasteDialog.shown && (
{appState.openDialog?.name === "charts" && (
<PasteChartDialog
setAppState={setAppState}
appState={appState}
data={appState.openDialog.data}
rawText={appState.openDialog.rawText}
onClose={() =>
setAppState({
pasteDialog: { shown: false, data: null },
openDialog: null,
})
}
/>
@@ -2,6 +2,40 @@
.excalidraw {
.PasteChartDialog {
.PasteChartDialog__title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.PasteChartDialog__titleText {
min-width: 0;
}
.PasteChartDialog__reshuffleBtn {
margin-left: auto;
flex: 0 0 auto;
width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
color: var(--text-primary-color);
transition: transform 120ms ease, background-color 120ms ease,
color 120ms ease;
user-select: none;
&:hover {
color: $color-blue-6;
}
&:active {
transform: scale(0.94);
}
}
@include isMobile {
.Island {
display: flex;
@@ -11,35 +45,61 @@
.container {
display: flex;
align-items: center;
justify-content: space-around;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
@include isMobile {
flex-direction: column;
justify-content: center;
align-items: stretch;
}
}
.ChartPreview {
margin: 8px;
text-align: center;
width: 192px;
height: 128px;
border-radius: 2px;
padding: 1px;
width: 260px;
min-height: 190px;
border-radius: 8px;
padding: 10px;
border: 1px solid $color-gray-4;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 10px;
background: transparent;
div {
display: inline-block;
.ChartPreview__canvas {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
overflow: hidden;
}
.ChartPreview__label {
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
text-align: center;
color: var(--text-primary-color);
}
svg {
max-height: 120px;
max-width: 186px;
max-height: 144px;
max-width: 100%;
}
&:hover {
padding: 0;
border: 2px solid $color-blue-5;
border-color: $color-blue-5;
}
&:active {
border-color: $color-blue-5;
box-shadow: 0 0 0 1px $color-blue-5;
transform: scale(0.98);
}
&:focus-visible {
border-color: $color-blue-5;
box-shadow: 0 0 0 1px $color-blue-5;
}
@include isMobile {
width: 100%;
min-height: 200px;
}
}
}
@@ -1,35 +1,57 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import { newTextElement } from "@excalidraw/element";
import type { ChartType } from "@excalidraw/element/types";
import { trackEvent } from "../analytics";
import { renderSpreadsheet } from "../charts";
import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts";
import { t } from "../i18n";
import { exportToSvg } from "../scene/export";
import { useUIAppState } from "../context/ui-appState";
import { useApp } from "./App";
import { Dialog } from "./Dialog";
import "./PasteChartDialog.scss";
import { bucketFillIcon } from "./icons";
import type { ChartElements, Spreadsheet } from "../charts";
import type { UIAppState } from "../types";
type OnPlainTextPaste = (rawText: string) => void;
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
const getChartTypeLabel = (chartType: ChartType) => {
switch (chartType) {
case "bar":
return t("labels.chartType_bar");
case "line":
return t("labels.chartType_line");
case "radar":
return t("labels.chartType_radar");
default:
return chartType;
}
};
const ChartPreviewBtn = (props: {
spreadsheet: Spreadsheet | null;
chartType: ChartType;
selected: boolean;
colorSeed: number;
onClick: OnInsertChart;
}) => {
const previewRef = useRef<HTMLDivElement | null>(null);
const [chartElements, setChartElements] = useState<ChartElements | null>(
null,
);
const { theme } = useUIAppState();
useLayoutEffect(() => {
if (!props.spreadsheet) {
setChartElements(null);
return;
}
@@ -38,7 +60,13 @@ const ChartPreviewBtn = (props: {
props.spreadsheet,
0,
0,
props.colorSeed,
);
if (!elements) {
setChartElements(null);
previewRef.current?.replaceChildren();
return;
}
setChartElements(elements);
let svg: SVGSVGElement;
const previewNode = previewRef.current!;
@@ -49,6 +77,7 @@ const ChartPreviewBtn = (props: {
{
exportBackground: false,
viewBackgroundColor: "#fff",
exportWithDarkMode: theme === "dark",
},
null, // files
{
@@ -58,42 +87,108 @@ const ChartPreviewBtn = (props: {
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
if (props.selected) {
(previewNode.parentNode as HTMLDivElement).focus();
}
})();
return () => {
previewNode.replaceChildren();
};
}, [props.spreadsheet, props.chartType, props.selected]);
}, [props.spreadsheet, props.chartType, props.colorSeed, theme]);
const chartTypeLabel = getChartTypeLabel(props.chartType);
return (
<button
type="button"
className="ChartPreview"
aria-label={chartTypeLabel}
onClick={() => {
if (chartElements) {
props.onClick(props.chartType, chartElements);
}
}}
>
<div ref={previewRef} />
<div className="ChartPreview__canvas" ref={previewRef} />
<div className="ChartPreview__label">{chartTypeLabel}</div>
</button>
);
};
const PlainTextPreviewBtn = (props: {
rawText: string;
onClick: OnPlainTextPaste;
}) => {
const previewRef = useRef<HTMLDivElement | null>(null);
const { theme } = useUIAppState();
useLayoutEffect(() => {
if (!props.rawText) {
return;
}
const textElement = newTextElement({
text: props.rawText,
x: 0,
y: 0,
});
const previewNode = previewRef.current!;
(async () => {
const svg = await exportToSvg(
[textElement],
{
exportBackground: false,
viewBackgroundColor: "#fff",
exportWithDarkMode: theme === "dark",
},
null,
{
skipInliningFonts: true,
},
);
svg.querySelector(".style-fonts")?.remove();
previewNode.replaceChildren();
previewNode.appendChild(svg);
})();
return () => {
previewNode.replaceChildren();
};
}, [props.rawText, theme]);
return (
<button
type="button"
className="ChartPreview"
aria-label={t("labels.chartType_plaintext")}
onClick={() => {
props.onClick(props.rawText);
}}
>
<div className="ChartPreview__canvas" ref={previewRef} />
<div className="ChartPreview__label">
{t("labels.chartType_plaintext")}
</div>
</button>
);
};
export const PasteChartDialog = ({
setAppState,
appState,
data,
rawText,
onClose,
}: {
appState: UIAppState;
data: Spreadsheet;
rawText: string;
onClose: () => void;
setAppState: React.Component<any, UIAppState>["setState"];
}) => {
const { onInsertElements } = useApp();
const { onInsertElements, focusContainer } = useApp();
const [colorSeed, setColorSeed] = useState(Math.random());
const handleReshuffleColors = React.useCallback(() => {
setColorSeed(Math.random());
}, []);
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
@@ -103,36 +198,72 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements);
trackEvent("paste", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {
shown: false,
data: null,
},
onClose();
focusContainer();
};
const handlePlainTextClick = (rawText: string) => {
const textElement = newTextElement({
text: rawText,
x: 0,
y: 0,
});
onInsertElements([textElement]);
trackEvent("paste", "chart", "plaintext");
onClose();
focusContainer();
};
return (
<Dialog
size="small"
size="regular"
onCloseRequest={handleClose}
title={t("labels.pasteCharts")}
title={
<div className="PasteChartDialog__title">
<div className="PasteChartDialog__titleText">
{t("labels.pasteCharts")}
</div>
<div
className="PasteChartDialog__reshuffleBtn"
onClick={handleReshuffleColors}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleReshuffleColors();
}
}}
>
{bucketFillIcon}
</div>
</div>
}
className={"PasteChartDialog"}
autofocus={false}
>
<div className={"container"}>
<ChartPreviewBtn
chartType="bar"
spreadsheet={appState.pasteDialog.data}
selected={appState.currentChartType === "bar"}
onClick={handleChartClick}
/>
<ChartPreviewBtn
chartType="line"
spreadsheet={appState.pasteDialog.data}
selected={appState.currentChartType === "line"}
onClick={handleChartClick}
/>
{(["bar", "line", "radar"] as const).map((chartType) => {
if (!isSpreadsheetValidForChartType(data, chartType)) {
return null;
}
return (
<ChartPreviewBtn
key={chartType}
chartType={chartType}
spreadsheet={data}
colorSeed={colorSeed}
onClick={handleChartClick}
/>
);
})}
{rawText && (
<PlainTextPreviewBtn
rawText={rawText}
onClick={handlePlainTextClick}
/>
)}
</div>
</Dialog>
);