From b4d2720ed106540afd2deb1e4cb45063b6f98559 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Mar 2026 03:31:24 +0000 Subject: [PATCH] Add table import for tabular paste and CSV drop Co-authored-by: Alex Tapper --- packages/excalidraw/charts/charts.table.ts | 296 +++++++++++++++++++ packages/excalidraw/charts/index.ts | 11 + packages/excalidraw/components/App.tsx | 104 ++++++- packages/excalidraw/tests/charts.test.tsx | 30 +- packages/excalidraw/tests/clipboard.test.tsx | 51 ++++ 5 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 packages/excalidraw/charts/charts.table.ts diff --git a/packages/excalidraw/charts/charts.table.ts b/packages/excalidraw/charts/charts.table.ts new file mode 100644 index 000000000000..a2ab14795adf --- /dev/null +++ b/packages/excalidraw/charts/charts.table.ts @@ -0,0 +1,296 @@ +import { getFontString, getLineHeight } from "@excalidraw/common"; + +import { convertToExcalidrawElements, measureText } from "@excalidraw/element"; + +import type { ExcalidrawElementSkeleton } from "@excalidraw/element"; +import type { + ExcalidrawElement, + ExcalidrawGenericElement, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; + +type SupportedDelimiter = "\t" | "," | ";"; + +const SUPPORTED_DELIMITERS = ["\t", ",", ";"] as const; +const MAX_TABLE_CELLS = 2500; + +const CELL_PADDING_X = 16; +const CELL_PADDING_Y = 10; +const MIN_CELL_WIDTH = 80; +const MAX_CELL_WIDTH = 320; +const MIN_CELL_HEIGHT = 40; +const MAX_CELL_HEIGHT = 200; + +export type ParsedTabularData = { + rows: string[][]; + delimiter: SupportedDelimiter; +}; + +export type ParseTabularDataResult = + | { ok: true; data: ParsedTabularData } + | { ok: false; reason: string }; + +export type TableRenderStyle = { + strokeColor: ExcalidrawGenericElement["strokeColor"]; + textColor: ExcalidrawTextElement["strokeColor"]; + backgroundColor: ExcalidrawGenericElement["backgroundColor"]; + fillStyle: ExcalidrawGenericElement["fillStyle"]; + strokeWidth: ExcalidrawGenericElement["strokeWidth"]; + strokeStyle: ExcalidrawGenericElement["strokeStyle"]; + roughness: ExcalidrawGenericElement["roughness"]; + opacity: ExcalidrawGenericElement["opacity"]; + fontSize: ExcalidrawTextElement["fontSize"]; + fontFamily: ExcalidrawTextElement["fontFamily"]; +}; + +type Candidate = { + delimiter: SupportedDelimiter; + rows: string[][]; + columnCount: number; + multiColumnRowCount: number; + valid: boolean; +}; + +const parseDelimitedText = (rawText: string, delimiter: SupportedDelimiter) => { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let inQuotes = false; + + for (let i = 0; i < rawText.length; i++) { + const char = rawText[i]; + + if (char === '"') { + if (inQuotes && rawText[i + 1] === '"') { + cell += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === delimiter && !inQuotes) { + row.push(cell); + cell = ""; + continue; + } + + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && rawText[i + 1] === "\n") { + i++; + } + row.push(cell); + rows.push(row); + row = []; + cell = ""; + continue; + } + + cell += char; + } + + if (inQuotes) { + return null; + } + + row.push(cell); + rows.push(row); + + return rows; +}; + +const getCandidate = (text: string, delimiter: SupportedDelimiter): Candidate => { + const rows = parseDelimitedText(text, delimiter); + + if (!rows) { + return { + delimiter, + rows: [], + columnCount: 0, + multiColumnRowCount: 0, + valid: false, + }; + } + + const normalizedRows = rows + .map((row) => row.map((cell) => cell.trim())) + .filter((row) => row.some((cell) => cell.length > 0)); + + const columnCount = normalizedRows.reduce( + (max, row) => Math.max(max, row.length), + 0, + ); + const paddedRows = normalizedRows.map((row) => + row.length < columnCount + ? [...row, ...Array(columnCount - row.length).fill("")] + : row, + ); + const multiColumnRowCount = paddedRows.filter((row) => row.length > 1).length; + + return { + delimiter, + rows: paddedRows, + columnCount, + multiColumnRowCount, + valid: + paddedRows.length >= 2 && + columnCount >= 2 && + multiColumnRowCount >= 2 && + paddedRows.every((row) => row.length === columnCount), + }; +}; + +export const tryParseTabularData = (text: string): ParseTabularDataResult => { + const normalizedText = text.replace(/^\uFEFF/, ""); + + const bestCandidate = SUPPORTED_DELIMITERS.map((delimiter) => + getCandidate(normalizedText, delimiter), + ) + .filter((candidate) => candidate.valid) + .reduce((best, candidate) => { + if (!best) { + return candidate; + } + + if (candidate.columnCount > best.columnCount) { + return candidate; + } + + if (candidate.columnCount === best.columnCount) { + return candidate.rows.length > best.rows.length ? candidate : best; + } + + return best; + }, null); + + if (!bestCandidate) { + return { ok: false, reason: "No tabular data" }; + } + + if (bestCandidate.columnCount * bestCandidate.rows.length > MAX_TABLE_CELLS) { + return { ok: false, reason: "Too many cells" }; + } + + return { + ok: true, + data: { + rows: bestCandidate.rows, + delimiter: bestCandidate.delimiter, + }, + }; +}; + +const getCellMetrics = ({ + text, + fontString, + lineHeight, + lineHeightPx, +}: { + text: string; + fontString: ReturnType; + lineHeight: ReturnType; + lineHeightPx: number; +}) => { + const lines = text ? text.split("\n") : [""]; + const maxLineWidth = Math.max( + ...lines.map((line) => measureText(line || " ", fontString, lineHeight).width), + ); + + return { + width: Math.min( + MAX_CELL_WIDTH, + Math.max(MIN_CELL_WIDTH, maxLineWidth + CELL_PADDING_X * 2), + ), + height: Math.min( + MAX_CELL_HEIGHT, + Math.max(MIN_CELL_HEIGHT, lineHeightPx * Math.max(lines.length, 1) + CELL_PADDING_Y * 2), + ), + }; +}; + +export const renderTabularDataAsTable = ({ + rows, + x = 0, + y = 0, + style, +}: { + rows: string[][]; + x?: number; + y?: number; + style: TableRenderStyle; +}): readonly ExcalidrawElement[] => { + if (!rows.length || !rows[0]?.length) { + return []; + } + + const rowCount = rows.length; + const columnCount = rows[0].length; + const fontString = getFontString({ + fontFamily: style.fontFamily, + fontSize: style.fontSize, + }); + const lineHeight = getLineHeight(style.fontFamily); + const lineHeightPx = lineHeight * style.fontSize; + + const columnWidths = new Array(columnCount).fill(MIN_CELL_WIDTH); + const rowHeights = new Array(rowCount).fill(MIN_CELL_HEIGHT); + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + const metrics = getCellMetrics({ + text: rows[rowIndex][columnIndex] || "", + fontString, + lineHeight, + lineHeightPx, + }); + columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], metrics.width); + rowHeights[rowIndex] = Math.max(rowHeights[rowIndex], metrics.height); + } + } + + const skeletonElements: ExcalidrawElementSkeleton[] = []; + let currentY = y; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + let currentX = x; + + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + const text = rows[rowIndex][columnIndex] || ""; + const width = columnWidths[columnIndex]; + const height = rowHeights[rowIndex]; + + skeletonElements.push({ + type: "rectangle", + x: currentX, + y: currentY, + width, + height, + strokeColor: style.strokeColor, + backgroundColor: style.backgroundColor, + fillStyle: style.fillStyle, + strokeWidth: style.strokeWidth, + strokeStyle: style.strokeStyle, + roughness: style.roughness, + opacity: style.opacity, + roundness: null, + label: text + ? { + text, + strokeColor: style.textColor, + fontSize: style.fontSize, + fontFamily: style.fontFamily, + textAlign: "left", + verticalAlign: "middle", + } + : undefined, + }); + + currentX += width; + } + + currentY += rowHeights[rowIndex]; + } + + return convertToExcalidrawElements(skeletonElements); +}; diff --git a/packages/excalidraw/charts/index.ts b/packages/excalidraw/charts/index.ts index d806546a4969..f70ed6bc562e 100644 --- a/packages/excalidraw/charts/index.ts +++ b/packages/excalidraw/charts/index.ts @@ -7,6 +7,10 @@ import { tryParseNumber, tryParseSpreadsheet, } from "./charts.parse"; +import { + renderTabularDataAsTable, + tryParseTabularData, +} from "./charts.table"; import { renderRadarChart } from "./charts.radar"; import type { ChartElements, Spreadsheet } from "./charts.types"; @@ -20,6 +24,13 @@ export { export { isSpreadsheetValidForChartType } from "./charts.helpers"; export { tryParseCells, tryParseNumber, tryParseSpreadsheet }; +export { + type ParseTabularDataResult, + type ParsedTabularData, + type TableRenderStyle, + renderTabularDataAsTable, + tryParseTabularData, +} from "./charts.table"; export const renderSpreadsheet = ( chartType: ChartType, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0b361e0e709b..4bc0d2e4d12f 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -369,6 +369,7 @@ import { } from "../scene"; import { getStateForZoom } from "../scene/zoom"; import { + blobToArrayBuffer, dataURLToString, generateIdFromFile, getDataURL, @@ -426,7 +427,11 @@ import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; -import { tryParseSpreadsheet } from "../charts"; +import { + renderTabularDataAsTable, + tryParseSpreadsheet, + tryParseTabularData, +} from "../charts"; import ConvertElementTypePopup, { getConversionTypeFromElements, @@ -579,6 +584,13 @@ const YOUTUBE_VIDEO_STATES = new Map< ExcalidrawElement["id"], ValueOf >(); +const TABULAR_FILE_EXTENSIONS = new Set(["csv", "tsv"]); +const TABULAR_FILE_MIME_TYPES = new Set([ + "text/csv", + "application/csv", + "text/tab-separated-values", + "application/vnd.ms-excel", +]); let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; @@ -3516,6 +3528,75 @@ class App extends React.Component { } }; + private isTabularDataFile = (file: File) => { + const extension = file.name?.split(".").pop()?.toLowerCase(); + const mimeType = (file.type || "").toLowerCase(); + + return ( + (extension ? TABULAR_FILE_EXTENSIONS.has(extension) : false) || + TABULAR_FILE_MIME_TYPES.has(mimeType) + ); + }; + + private readFileAsText = async (file: File) => { + if (typeof file.text === "function") { + return file.text(); + } + + const arrayBuffer = await blobToArrayBuffer(file); + if (typeof TextDecoder !== "undefined") { + return new TextDecoder().decode(arrayBuffer); + } + + return String.fromCharCode(...new Uint8Array(arrayBuffer)); + }; + + private insertTableFromRows = ( + rows: string[][], + position: "cursor" | { clientX: number; clientY: number }, + ) => { + const tableElements = renderTabularDataAsTable({ + rows, + style: { + strokeColor: this.state.currentItemStrokeColor, + textColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + opacity: this.state.currentItemOpacity, + fontSize: this.state.currentItemFontSize, + fontFamily: this.state.currentItemFontFamily, + }, + }); + + if (!tableElements.length) { + return false; + } + + this.addElementsFromPasteOrLibrary({ + elements: tableElements, + files: null, + position, + retainSeed: true, + }); + + return true; + }; + + private insertTableFromText = ( + text: string, + position: "cursor" | { clientX: number; clientY: number }, + ) => { + const result = tryParseTabularData(text); + if (!result.ok) { + return false; + } + + return this.insertTableFromRows(result.data.rows, position); + }; + // TODO: Cover with tests private async insertClipboardContent( data: ClipboardData, @@ -3546,9 +3627,14 @@ class App extends React.Component { return; } - // ------------------- Spreadsheet ------------------- + // ------------------- Tabular data / Spreadsheet ------------------- if (!isPlainPaste && data.text) { + if (this.insertTableFromText(data.text, "cursor")) { + trackEvent("paste", "table", "tabular"); + return; + } + const result = tryParseSpreadsheet(data.text); if (result.ok) { this.setState({ @@ -11509,6 +11595,20 @@ class App extends React.Component { } } + const tabularFile = fileItems + .map((item) => item.file) + .find((file) => this.isTabularDataFile(file)); + if (tabularFile) { + try { + if (this.insertTableFromText(await this.readFileAsText(tabularFile), event)) { + trackEvent("drop", "table", "file"); + return; + } + } catch (error: any) { + console.error(error); + } + } + const imageFiles = fileItems .map((data) => data.file) .filter((file) => isSupportedImageFile(file)); diff --git a/packages/excalidraw/tests/charts.test.tsx b/packages/excalidraw/tests/charts.test.tsx index 8f2274c824ad..4d70888e8036 100644 --- a/packages/excalidraw/tests/charts.test.tsx +++ b/packages/excalidraw/tests/charts.test.tsx @@ -1,4 +1,4 @@ -import { tryParseSpreadsheet } from "../charts"; +import { tryParseSpreadsheet, tryParseTabularData } from "../charts"; describe("tryParseSpreadsheet", () => { it("works for numbers with comma in them", () => { @@ -162,3 +162,31 @@ B\t20`, }); }); }); + +describe("tryParseTabularData", () => { + it("parses csv with quoted values", () => { + const result = tryParseTabularData( + `name,notes,score +"Doe, Jane","Loves ""tables""",8 +John,"Adds +multiline notes",10`, + ); + + expect(result).toEqual({ + ok: true, + data: { + delimiter: ",", + rows: [ + ["name", "notes", "score"], + ["Doe, Jane", 'Loves "tables"', "8"], + ["John", "Adds\nmultiline notes", "10"], + ], + }, + }); + }); + + it("rejects a non-tabular single line", () => { + const result = tryParseTabularData("hello, world"); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 7386b360cd99..cd25fac3eebc 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -121,6 +121,57 @@ describe("general paste behavior", () => { }); }); +describe("tabular import", () => { + it("pastes tabular text as an editable table", async () => { + pasteWithCtrlCmdV("Name\tRole\nAlice\tEngineer\nBob\tDesigner"); + + await waitFor(() => { + const rectangles = h.elements.filter((element) => element.type === "rectangle"); + const texts = h.elements.filter((element) => element.type === "text"); + expect(rectangles.length).toBe(6); + expect(texts.length).toBe(6); + + const firstCell = rectangles[0]; + const firstTextBinding = firstCell.boundElements?.find( + (binding) => binding.type === "text", + ); + expect(firstTextBinding).toBeTruthy(); + + const boundText = h.elements.find( + (element) => element.id === firstTextBinding?.id, + ); + expect(boundText).toEqual( + expect.objectContaining({ + type: "text", + containerId: firstCell.id, + }), + ); + }); + }); + + it("imports dropped csv file as a table", async () => { + const file = new File(["Name,Role\nAlice,Engineer\nBob,Designer"], "team.csv", { + type: "text/csv", + }); + + await API.drop([{ kind: "file", file }]); + + await waitFor(() => { + const rectangles = h.elements.filter((element) => element.type === "rectangle"); + const texts = h.elements.filter((element) => element.type === "text"); + expect(rectangles.length).toBe(6); + expect(texts.length).toBe(6); + expect( + texts.some( + (element) => + element.type === "text" && + (element.text === "Engineer" || element.text === "Designer"), + ), + ).toBe(true); + }); + }); +}); + describe("paste text as single lines", () => { it("should create an element for each line when copying with Ctrl/Cmd+V", async () => { const text = "sajgfakfn\naaksfnknas\nakefnkasf";