feat(editor): support radar chart and multiple series for other chart types (#10824)
This commit is contained in:
@@ -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 -------------------
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user