Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions packages/excalidraw/charts/charts.table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
COLOR_PALETTE,
DEFAULT_FONT_FAMILY,
FONT_FAMILY,
FONT_SIZES,
getFontString,
getLineHeight,
ROUNDNESS,
} from "@excalidraw/common";

import { measureText, newElement, newTextElement } from "@excalidraw/element";

import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";

import type { ChartElements } from "./charts.types";

const TABLE_CELL_PADDING = 12;
const TABLE_MIN_COL_WIDTH = 60;
const TABLE_MAX_COL_WIDTH = 220;
const TABLE_ROW_HEIGHT_PADDING = 14;

const HEADER_BG = "#e8eaed";
const CELL_BG = "#ffffff";
const BORDER_COLOR = COLOR_PALETTE.black;

export const renderTable = (
cells: string[][],
x: number,
y: number,
): ChartElements | null => {
if (cells.length < 1 || cells[0].length < 1) {
return null;
}

const numRows = cells.length;
const numCols = cells[0].length;

const fontFamily = DEFAULT_FONT_FAMILY;
const headerFontFamily = FONT_FAMILY["Lilita One"];
const fontSize = FONT_SIZES.md;
const lineHeight = getLineHeight(fontFamily);
const headerLineHeight = getLineHeight(headerFontFamily);
const fontString = getFontString({ fontFamily, fontSize });
const headerFontString = getFontString({
fontFamily: headerFontFamily,
fontSize,
});

const colWidths = new Array(numCols).fill(TABLE_MIN_COL_WIDTH);
for (let col = 0; col < numCols; col++) {
for (let row = 0; row < numRows; row++) {
const cellText = cells[row][col] || "";
const isHeader = row === 0;
const metrics = measureText(
cellText,
isHeader ? headerFontString : fontString,
isHeader ? headerLineHeight : lineHeight,
);
const desiredWidth = metrics.width + TABLE_CELL_PADDING * 2;
colWidths[col] = Math.min(
TABLE_MAX_COL_WIDTH,
Math.max(colWidths[col], desiredWidth),
);
}
}

const rowHeights = new Array(numRows).fill(0);
for (let row = 0; row < numRows; row++) {
const isHeader = row === 0;
const metrics = measureText(
"Ag",
isHeader ? headerFontString : fontString,
isHeader ? headerLineHeight : lineHeight,
);
rowHeights[row] = metrics.height + TABLE_ROW_HEIGHT_PADDING;
}

const elements: NonDeletedExcalidrawElement[] = [];

const totalWidth = colWidths.reduce((sum, w) => sum + w, 0);
const totalHeight = rowHeights.reduce((sum, h) => sum + h, 0);

elements.push(
newElement({
type: "rectangle",
x,
y,
width: totalWidth,
height: totalHeight,
backgroundColor: CELL_BG,
strokeColor: BORDER_COLOR,
fillStyle: "solid",
strokeWidth: 1,
roughness: 0,
opacity: 100,
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS },
}),
);

let currentY = y;
for (let row = 0; row < numRows; row++) {
const isHeader = row === 0;
let currentX = x;

for (let col = 0; col < numCols; col++) {
const cellWidth = colWidths[col];
const cellHeight = rowHeights[row];

elements.push(
newElement({
type: "rectangle",
x: currentX,
y: currentY,
width: cellWidth,
height: cellHeight,
backgroundColor: isHeader ? HEADER_BG : "transparent",
strokeColor: BORDER_COLOR,
fillStyle: "solid",
strokeWidth: 1,
roughness: 0,
opacity: 100,
roundness: null,
}),
);

const cellText = cells[row][col] || "";
if (cellText) {
elements.push(
newTextElement({
text: cellText,
x: currentX + TABLE_CELL_PADDING,
y: currentY + cellHeight / 2,
fontFamily: isHeader ? headerFontFamily : fontFamily,
fontSize,
lineHeight: isHeader ? headerLineHeight : lineHeight,
textAlign: "left",
verticalAlign: "middle",
strokeColor: COLOR_PALETTE.black,
opacity: 100,
}),
);
}

currentX += cellWidth;
}
currentY += rowHeights[row];
}

return elements;
};
41 changes: 41 additions & 0 deletions packages/excalidraw/charts/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Parses a CSV/TSV/semicolon-separated text string into a 2D array of cells.
* Unlike tryParseSpreadsheet, this does not require numeric data — any
* consistently-columned tabular text qualifies.
*/
export const tryParseCSVCells = (text: string): string[][] | null => {
const parseDelimitedLines = (delimiter: "\t" | "," | ";") =>
text
.replace(/\r\n?/g, "\n")
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => line.split(delimiter).map((cell) => cell.trim()));

const candidates = (["\t", ",", ";"] as const).map((delimiter) => {
const parsed = parseDelimitedLines(delimiter);
const numCols = parsed[0]?.length ?? 0;
const isConsistent =
parsed.length > 0 && parsed.every((line) => line.length === numCols);
return { delimiter, parsed, numCols, isConsistent };
});

const best =
candidates.find((c) => c.isConsistent && c.numCols > 1) ??
candidates.find((c) => c.isConsistent) ??
candidates[0];

const lines = best.parsed;

if (lines.length === 0) {
return null;
}

const numColsFirstLine = lines[0].length;
const isTabular = lines.every((line) => line.length === numColsFirstLine);

if (!isTabular || numColsFirstLine < 2 || lines.length < 2) {
return null;
}

return lines;
};
3 changes: 3 additions & 0 deletions packages/excalidraw/charts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
tryParseSpreadsheet,
} from "./charts.parse";
import { renderRadarChart } from "./charts.radar";
import { renderTable } from "./charts.table";
import { tryParseCSVCells } from "./csv";

import type { ChartElements, Spreadsheet } from "./charts.types";

Expand All @@ -20,6 +22,7 @@ export {

export { isSpreadsheetValidForChartType } from "./charts.helpers";
export { tryParseCells, tryParseNumber, tryParseSpreadsheet };
export { tryParseCSVCells, renderTable };

export const renderSpreadsheet = (
chartType: ChartType,
Expand Down
41 changes: 37 additions & 4 deletions packages/excalidraw/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ import { EraserTrail } from "../eraser";

import { getShortcutKey } from "../shortcut";

import { tryParseSpreadsheet } from "../charts";
import { tryParseSpreadsheet, tryParseCSVCells, renderTable } from "../charts";

import ConvertElementTypePopup, {
getConversionTypeFromElements,
Expand Down Expand Up @@ -3546,16 +3546,19 @@ class App extends React.Component<AppProps, AppState> {
return;
}

// ------------------- Spreadsheet -------------------
// ------------------- Spreadsheet / Table -------------------

if (!isPlainPaste && data.text) {
const result = tryParseSpreadsheet(data.text);
if (result.ok) {
const csvCells = tryParseCSVCells(data.text);

if (result.ok || csvCells) {
this.setState({
openDialog: {
name: "charts",
data: result.data,
data: result.ok ? result.data : null,
rawText: data.text,
csvCells,
},
});
return;
Expand Down Expand Up @@ -11516,6 +11519,36 @@ class App extends React.Component<AppProps, AppState> {
if (imageFiles.length > 0 && this.isToolSupported("image")) {
return this.insertImages(imageFiles, sceneX, sceneY);
}

// ------------------- CSV files → Table -------------------
const csvFiles = fileItems
.map((data) => data.file)
.filter(
(file) =>
file.type === "text/csv" || file.name?.toLowerCase().endsWith(".csv"),
);

if (csvFiles.length > 0) {
try {
const csvText = await csvFiles[0].text();
const csvCells = tryParseCSVCells(csvText);
if (csvCells) {
const tableElements = renderTable(csvCells, sceneX, sceneY);
if (tableElements) {
this.addElementsFromPasteOrLibrary({
elements: tableElements,
position: event,
files: null,
});
return;
}
}
} catch (error: any) {
this.setState({ errorMessage: error.message });
}
return;
}

const excalidrawLibrary_ids = dataTransferList.getData(
MIME_TYPES.excalidrawlibIds,
);
Expand Down
1 change: 1 addition & 0 deletions packages/excalidraw/components/LayerUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ const LayerUI = ({
<PasteChartDialog
data={appState.openDialog.data}
rawText={appState.openDialog.rawText}
csvCells={appState.openDialog.csvCells}
onClose={() =>
setAppState({
openDialog: null,
Expand Down
Loading