From 611b2d4593e81a1a85830db714d03a6a0649d3dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Mar 2026 06:38:23 +0000 Subject: [PATCH] feat: add CSV/tabular data paste and drag-drop to create table on canvas - Add tryParseCSVCells() in charts/csv.ts for general CSV/TSV parsing that works with any text data (not just numeric) - Add renderTable() in charts/charts.table.ts to compose a table from rectangle and text elements with styled header row - Extend PasteChartDialog with a Table option alongside chart types - Update insertClipboardContent to detect tabular data (even non-numeric) and show the table option in the paste dialog - Add CSV file drag-and-drop handler in handleAppOnDrop that creates a table directly on drop - Update openDialog type to support optional csvCells and nullable data - Add i18n labels for table-related strings --- packages/excalidraw/charts/charts.table.ts | 150 +++++++++++++++++ packages/excalidraw/charts/csv.ts | 41 +++++ packages/excalidraw/charts/index.ts | 3 + packages/excalidraw/components/App.tsx | 41 ++++- packages/excalidraw/components/LayerUI.tsx | 1 + .../components/PasteChartDialog.tsx | 156 ++++++++++++++---- packages/excalidraw/locales/en.json | 2 + packages/excalidraw/types.ts | 7 +- 8 files changed, 362 insertions(+), 39 deletions(-) create mode 100644 packages/excalidraw/charts/charts.table.ts create mode 100644 packages/excalidraw/charts/csv.ts diff --git a/packages/excalidraw/charts/charts.table.ts b/packages/excalidraw/charts/charts.table.ts new file mode 100644 index 000000000000..4421b3ad1745 --- /dev/null +++ b/packages/excalidraw/charts/charts.table.ts @@ -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; +}; diff --git a/packages/excalidraw/charts/csv.ts b/packages/excalidraw/charts/csv.ts new file mode 100644 index 000000000000..811417b502cb --- /dev/null +++ b/packages/excalidraw/charts/csv.ts @@ -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; +}; diff --git a/packages/excalidraw/charts/index.ts b/packages/excalidraw/charts/index.ts index d806546a4969..70d1d5585a06 100644 --- a/packages/excalidraw/charts/index.ts +++ b/packages/excalidraw/charts/index.ts @@ -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"; @@ -20,6 +22,7 @@ export { export { isSpreadsheetValidForChartType } from "./charts.helpers"; export { tryParseCells, tryParseNumber, tryParseSpreadsheet }; +export { tryParseCSVCells, renderTable }; export const renderSpreadsheet = ( chartType: ChartType, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0b361e0e709b..c8e752c9bf1a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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, @@ -3546,16 +3546,19 @@ class App extends React.Component { 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; @@ -11516,6 +11519,36 @@ class App extends React.Component { 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, ); diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 85d2701b1142..5b6c46e2d5fe 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -560,6 +560,7 @@ const LayerUI = ({ setAppState({ openDialog: null, diff --git a/packages/excalidraw/components/PasteChartDialog.tsx b/packages/excalidraw/components/PasteChartDialog.tsx index 19c32717669e..e7aadc729fc7 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -5,7 +5,11 @@ import { newTextElement } from "@excalidraw/element"; import type { ChartType } from "@excalidraw/element/types"; import { trackEvent } from "../analytics"; -import { isSpreadsheetValidForChartType, renderSpreadsheet } from "../charts"; +import { + isSpreadsheetValidForChartType, + renderSpreadsheet, + renderTable, +} from "../charts"; import { t } from "../i18n"; import { exportToSvg } from "../scene/export"; @@ -113,6 +117,71 @@ const ChartPreviewBtn = (props: { ); }; +const TablePreviewBtn = (props: { + csvCells: string[][]; + onClick: (elements: ChartElements) => void; +}) => { + const previewRef = useRef(null); + const [tableElements, setTableElements] = useState( + null, + ); + const { theme } = useUIAppState(); + + useLayoutEffect(() => { + if (!props.csvCells || props.csvCells.length < 2) { + setTableElements(null); + return; + } + + const elements = renderTable(props.csvCells, 0, 0); + if (!elements) { + setTableElements(null); + previewRef.current?.replaceChildren(); + return; + } + setTableElements(elements); + const previewNode = previewRef.current!; + + (async () => { + const svg = await exportToSvg( + elements, + { + exportBackground: false, + viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", + }, + null, + { + skipInliningFonts: true, + }, + ); + svg.querySelector(".style-fonts")?.remove(); + previewNode.replaceChildren(); + previewNode.appendChild(svg); + })(); + + return () => { + previewNode.replaceChildren(); + }; + }, [props.csvCells, theme]); + + return ( + + ); +}; + const PlainTextPreviewBtn = (props: { rawText: string; onClick: OnPlainTextPaste; @@ -176,10 +245,12 @@ const PlainTextPreviewBtn = (props: { export const PasteChartDialog = ({ data, rawText, + csvCells, onClose, }: { - data: Spreadsheet; + data: Spreadsheet | null; rawText: string; + csvCells?: string[][] | null; onClose: () => void; }) => { const { onInsertElements, focusContainer } = useApp(); @@ -202,6 +273,13 @@ export const PasteChartDialog = ({ focusContainer(); }; + const handleTableClick = (elements: ChartElements) => { + onInsertElements(elements); + trackEvent("paste", "chart", "table"); + onClose(); + focusContainer(); + }; + const handlePlainTextClick = (rawText: string) => { const textElement = newTextElement({ text: rawText, @@ -214,50 +292,60 @@ export const PasteChartDialog = ({ focusContainer(); }; + const hasChartData = data !== null; + const hasTableData = csvCells && csvCells.length >= 2; + const dialogTitle = hasChartData + ? t("labels.pasteCharts") + : t("labels.pasteChartsOrTable"); + return ( -
- {t("labels.pasteCharts")} -
-
{ - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleReshuffleColors(); - } - }} - > - {bucketFillIcon} -
+
{dialogTitle}
+ {hasChartData && ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleReshuffleColors(); + } + }} + > + {bucketFillIcon} +
+ )} } className={"PasteChartDialog"} autofocus={false} >
- {(["bar", "line", "radar"] as const).map((chartType) => { - if (!isSpreadsheetValidForChartType(data, chartType)) { - return null; - } - - return ( - - ); - })} + {hasChartData && + (["bar", "line", "radar"] as const).map((chartType) => { + if (!isSpreadsheetValidForChartType(data, chartType)) { + return null; + } + + return ( + + ); + })} + {hasTableData && ( + + )} {rawText && (