diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 165534741f24..8cd1b0416fa5 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -211,10 +211,27 @@ export const copyToClipboard = async ( }; /** internal, specific to parsing paste events. Do not reuse. */ +const serializeHTMLTable = (table: HTMLTableElement) => { + const rows = Array.from(table.rows) + .map((row) => + Array.from(row.cells) + .map((cell) => cell.textContent?.trim() ?? "") + .join("\t"), + ) + .filter((row) => row.trim().length > 0); + + return rows.join("\n"); +}; + function parseHTMLTree(el: ChildNode) { let result: PastedMixedContent = []; for (const node of el.childNodes) { - if (node.nodeType === 3) { + if (node instanceof HTMLTableElement) { + const tableText = serializeHTMLTable(node); + if (tableText) { + result.push({ type: "text", value: tableText }); + } + } else if (node.nodeType === 3) { const text = node.textContent?.trim(); if (text) { result.push({ type: "text", value: text }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0b361e0e709b..832c3c1ccf58 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -427,6 +427,7 @@ import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; import { tryParseSpreadsheet } from "../charts"; +import { parseTabularText, renderTableFromCells } from "../tables"; import ConvertElementTypePopup, { getConversionTypeFromElements, @@ -3516,6 +3517,92 @@ class App extends React.Component { } }; + private isTabularFile = (file: File) => { + const normalizedName = (file.name || "").toLowerCase(); + const normalizedType = (file.type || "").toLowerCase(); + return ( + normalizedName.endsWith(".csv") || + normalizedName.endsWith(".tsv") || + normalizedType === "text/csv" || + normalizedType === "application/csv" || + normalizedType === "text/tab-separated-values" || + normalizedType === "application/vnd.ms-excel" + ); + }; + + private insertTableFromText = ( + text: string, + sceneX: number, + sceneY: number, + ) => { + const parsed = parseTabularText(text); + if (!parsed.ok) { + return false; + } + return this.insertTableFromCells(parsed.data, sceneX, sceneY); + }; + + private insertTableFromCells = ( + cells: string[][], + sceneX: number, + sceneY: number, + ) => { + const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ + x: sceneX, + y: sceneY, + }); + const tableElements = renderTableFromCells({ + cells, + x: sceneX, + y: sceneY, + groupId: nanoid(), + frameId: topLayerFrame ? topLayerFrame.id : null, + style: { + strokeColor: 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, + locked: false, + }, + fontSize: this.state.currentItemFontSize, + fontFamily: this.state.currentItemFontFamily, + }); + + if (tableElements.length === 0) { + return false; + } + + this.scene.insertElements([...tableElements]); + this.store.scheduleCapture(); + this.setState({ + selectedElementIds: makeNextSelectedElementIds( + Object.fromEntries(tableElements.map((element) => [element.id, true])), + this.state, + ), + }); + return true; + }; + + private maybeInsertDroppedTabularFile = async ( + file: File, + sceneX: number, + sceneY: number, + ) => { + if (!this.isTabularFile(file)) { + return false; + } + try { + const text = await file.text(); + return this.insertTableFromText(text, sceneX, sceneY); + } catch (error: any) { + console.warn(`failed to parse dropped tabular file: ${error.message}`); + } + return false; + }; + // TODO: Cover with tests private async insertClipboardContent( data: ClipboardData, @@ -3549,6 +3636,10 @@ class App extends React.Component { // ------------------- Spreadsheet ------------------- if (!isPlainPaste && data.text) { + if (this.insertTableFromText(data.text, sceneX, sceneY)) { + return; + } + const result = tryParseSpreadsheet(data.text); if (result.ok) { this.setState({ @@ -3932,6 +4023,10 @@ class App extends React.Component { } else { const textNodes = mixedContent.filter((node) => node.type === "text"); if (textNodes.length) { + const textContent = textNodes.map((node) => node.value).join("\n"); + if (!isPlainPaste && this.insertTableFromText(textContent, sceneX, sceneY)) { + return; + } this.addTextFromPaste( textNodes.map((node) => node.value).join("\n\n"), isPlainPaste, @@ -11507,6 +11602,17 @@ class App extends React.Component { // if EncodingError, fall through to insert as regular image } } + + if (file && this.isTabularFile(file)) { + const didInsertTable = await this.maybeInsertDroppedTabularFile( + file, + sceneX, + sceneY, + ); + if (didInsertTable) { + return; + } + } } const imageFiles = fileItems @@ -11570,8 +11676,11 @@ class App extends React.Component { const textItem = dataTransferList.findByType(MIME_TYPES.text); - if (textItem) { + if (textItem && fileItems.length === 0) { const text = textItem.value; + if (this.insertTableFromText(text, sceneX, sceneY)) { + return; + } if ( text && embeddableURLValidator(text, this.props.validateEmbeddable) && diff --git a/packages/excalidraw/tables.ts b/packages/excalidraw/tables.ts new file mode 100644 index 000000000000..3675e40c8cb9 --- /dev/null +++ b/packages/excalidraw/tables.ts @@ -0,0 +1,255 @@ +import { getFontString, getLineHeight, normalizeEOL } from "@excalidraw/common"; + +import { + measureText, + newElement, + newTextElement, + wrapText, +} from "@excalidraw/element"; + +import type { + ExcalidrawElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, +} from "@excalidraw/element/types"; + +type Delimiter = "\t" | "," | ";"; + +export type ParseTabularTextResult = + | { ok: true; data: string[][] } + | { ok: false; reason: string }; + +const DELIMITERS: readonly Delimiter[] = ["\t", ",", ";"]; + +const parseDelimitedRows = (text: string, delimiter: Delimiter): string[][] => { + const rows: string[][] = []; + const normalizedText = normalizeEOL(text); + let currentRow: string[] = []; + let currentCell = ""; + let inQuotes = false; + let rowHasDelimiter = false; + + for (let i = 0; i < normalizedText.length; i++) { + const char = normalizedText[i]; + + if (char === '"') { + const nextChar = normalizedText[i + 1]; + if (inQuotes && nextChar === '"') { + currentCell += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (!inQuotes && char === delimiter) { + currentRow.push(currentCell.trim()); + currentCell = ""; + rowHasDelimiter = true; + continue; + } + + if (!inQuotes && char === "\n") { + currentRow.push(currentCell.trim()); + if (rowHasDelimiter || currentRow.some((cell) => cell.length > 0)) { + rows.push(currentRow); + } + currentRow = []; + currentCell = ""; + rowHasDelimiter = false; + continue; + } + + currentCell += char; + } + + if (inQuotes) { + return []; + } + + currentRow.push(currentCell.trim()); + if (rowHasDelimiter || currentRow.some((cell) => cell.length > 0)) { + rows.push(currentRow); + } + + return rows; +}; + +export const parseTabularText = (text: string): ParseTabularTextResult => { + if (text.trim().length === 0) { + return { ok: false, reason: "No values" }; + } + + const candidates = DELIMITERS.map((delimiter) => { + const parsed = parseDelimitedRows(text, delimiter); + const columnCount = parsed[0]?.length ?? 0; + const isConsistent = + parsed.length > 0 && parsed.every((row) => row.length === columnCount); + const score = + (isConsistent ? 1000 : 0) + columnCount * 10 + Math.min(parsed.length, 99); + return { delimiter, parsed, columnCount, isConsistent, score }; + }); + + let bestCandidate = candidates[0]; + for (const candidate of candidates.slice(1)) { + if (candidate.score > bestCandidate.score) { + bestCandidate = candidate; + } + } + + if (!bestCandidate.isConsistent) { + return { ok: false, reason: "Rows have inconsistent number of columns" }; + } + + if (bestCandidate.columnCount < 2) { + return { ok: false, reason: "Less than 2 columns" }; + } + + if (bestCandidate.parsed.length < 2) { + return { ok: false, reason: "Less than 2 rows" }; + } + + return { ok: true, data: bestCandidate.parsed }; +}; + +type TableRenderStyle = Pick< + ExcalidrawElement, + | "strokeColor" + | "backgroundColor" + | "fillStyle" + | "strokeWidth" + | "strokeStyle" + | "roughness" + | "opacity" + | "locked" +>; + +const CELL_PADDING_X = 12; +const CELL_PADDING_Y = 8; +const MIN_COLUMN_WIDTH = 80; +const MAX_COLUMN_WIDTH = 320; +const MIN_ROW_HEIGHT = 36; + +export const renderTableFromCells = (opts: { + cells: string[][]; + x: number; + y: number; + groupId?: string; + frameId: ExcalidrawElement["frameId"]; + style: TableRenderStyle; + fontSize: ExcalidrawTextElement["fontSize"]; + fontFamily: ExcalidrawTextElement["fontFamily"]; +}): readonly NonDeletedExcalidrawElement[] => { + const cells = opts.cells; + if (!cells.length || !cells[0]?.length) { + return []; + } + + const columnCount = cells[0].length; + const lineHeight = getLineHeight(opts.fontFamily); + const fontString = getFontString({ + fontFamily: opts.fontFamily, + fontSize: opts.fontSize, + }); + const groupIds = opts.groupId ? [opts.groupId] : []; + + const columnWidths = Array.from({ length: columnCount }, (_, columnIndex) => { + const maxCellWidth = Math.max( + ...cells.map((row) => + measureText(row[columnIndex] || "", fontString, lineHeight).width, + ), + ); + return Math.min( + MAX_COLUMN_WIDTH, + Math.max(MIN_COLUMN_WIDTH, maxCellWidth + CELL_PADDING_X * 2), + ); + }); + + const wrappedTexts = cells.map((row) => + row.map((cell, columnIndex) => + wrapText( + cell, + fontString, + Math.max(1, columnWidths[columnIndex] - CELL_PADDING_X * 2), + ), + ), + ); + + const rowHeights = wrappedTexts.map((row) => + Math.max( + MIN_ROW_HEIGHT, + ...row.map( + (wrappedText) => + measureText(wrappedText, fontString, lineHeight).height + + CELL_PADDING_Y * 2, + ), + ), + ); + + const elements: NonDeletedExcalidrawElement[] = []; + let currentY = opts.y; + + for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) { + let currentX = opts.x; + const rowHeight = rowHeights[rowIndex]; + + for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { + const columnWidth = columnWidths[columnIndex]; + const wrappedText = wrappedTexts[rowIndex][columnIndex]; + const textMetrics = measureText(wrappedText, fontString, lineHeight); + const textY = currentY + (rowHeight - textMetrics.height) / 2; + + elements.push( + newElement({ + type: "rectangle", + x: currentX, + y: currentY, + width: columnWidth, + height: rowHeight, + strokeColor: opts.style.strokeColor, + backgroundColor: opts.style.backgroundColor, + fillStyle: opts.style.fillStyle, + strokeWidth: opts.style.strokeWidth, + strokeStyle: opts.style.strokeStyle, + roughness: opts.style.roughness, + opacity: opts.style.opacity, + groupIds, + frameId: opts.frameId, + roundness: null, + locked: opts.style.locked, + }), + ); + + elements.push( + newTextElement({ + x: currentX + CELL_PADDING_X, + y: textY, + text: wrappedText, + originalText: cells[rowIndex][columnIndex], + strokeColor: opts.style.strokeColor, + backgroundColor: "transparent", + fillStyle: opts.style.fillStyle, + strokeWidth: opts.style.strokeWidth, + strokeStyle: opts.style.strokeStyle, + roughness: opts.style.roughness, + opacity: opts.style.opacity, + groupIds, + frameId: opts.frameId, + fontSize: opts.fontSize, + fontFamily: opts.fontFamily, + lineHeight, + textAlign: "left", + verticalAlign: "top", + locked: opts.style.locked, + }), + ); + + currentX += columnWidth; + } + + currentY += rowHeight; + } + + return elements; +}; diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 7386b360cd99..b4c2dd9321b4 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -192,6 +192,40 @@ describe("paste text as single lines", () => { }); }); +describe("paste tabular text", () => { + it("should create a table from CSV text with regular paste", async () => { + const text = "id,first_name,last_name\n1,Alice,Awtood\n2,Bob,Billby"; + pasteWithCtrlCmdV(text); + + await waitFor(() => { + const rectangles = h.elements.filter((element) => element.type === "rectangle"); + const tableText = h.elements.filter((element) => element.type === "text"); + expect(rectangles).toHaveLength(9); + expect(tableText).toHaveLength(9); + expect( + tableText.some( + (element) => element.type === "text" && element.originalText === "Alice", + ), + ).toBe(true); + }); + }); + + it("should preserve plain paste behavior for CSV text", async () => { + const text = "id,first_name,last_name\n1,Alice,Awtood\n2,Bob,Billby"; + pasteWithCtrlCmdShiftV(text); + + await waitFor(() => { + expect(h.elements).toHaveLength(1); + const firstElement = h.elements[0]; + expect(firstElement.type).toBe("text"); + if (firstElement.type !== "text") { + throw new Error("expected pasted element to be text"); + } + expect(firstElement.originalText).toBe(text); + }); + }); +}); + describe("paste text as a single element", () => { it("should create single text element when copying text with Ctrl/Cmd+Shift+V", async () => { const text = "sajgfakfn\naaksfnknas\nakefnkasf"; diff --git a/packages/excalidraw/tests/tables.test.ts b/packages/excalidraw/tests/tables.test.ts new file mode 100644 index 000000000000..72b5546c7b06 --- /dev/null +++ b/packages/excalidraw/tests/tables.test.ts @@ -0,0 +1,96 @@ +import { FONT_FAMILY } from "@excalidraw/common"; + +import { parseTabularText, renderTableFromCells } from "../tables"; + +describe("parseTabularText", () => { + it("parses CSV rows and trims cells", () => { + const result = parseTabularText( + "id, first_name, last_name\n1, Alice, Awtood\n2, Bob, Billby", + ); + + expect(result).toEqual({ + ok: true, + data: [ + ["id", "first_name", "last_name"], + ["1", "Alice", "Awtood"], + ["2", "Bob", "Billby"], + ], + }); + }); + + it("parses TSV", () => { + const result = parseTabularText("name\trole\nAlice\tEngineer\nBob\tDesigner"); + + expect(result).toEqual({ + ok: true, + data: [ + ["name", "role"], + ["Alice", "Engineer"], + ["Bob", "Designer"], + ], + }); + }); + + it("parses quoted CSV cells containing commas", () => { + const result = parseTabularText( + 'name,notes\nAlice,"likes apples, pears"\nBob,"uses commas, often"', + ); + + expect(result).toEqual({ + ok: true, + data: [ + ["name", "notes"], + ["Alice", "likes apples, pears"], + ["Bob", "uses commas, often"], + ], + }); + }); + + it("does not treat single-column multiline text as a table", () => { + const result = parseTabularText("first line\nsecond line\nthird line"); + + expect(result).toEqual({ + ok: false, + reason: "Less than 2 columns", + }); + }); +}); + +describe("renderTableFromCells", () => { + it("renders grouped rectangle and text elements per cell", () => { + const elements = renderTableFromCells({ + cells: [ + ["id", "name"], + ["1", "Alice"], + ["2", "Bob"], + ], + x: 100, + y: 200, + groupId: "table-group", + frameId: null, + style: { + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 1, + strokeStyle: "solid", + roughness: 0, + opacity: 100, + locked: false, + }, + fontFamily: FONT_FAMILY.Excalifont, + fontSize: 20, + }); + + const rectangles = elements.filter((element) => element.type === "rectangle"); + const texts = elements.filter((element) => element.type === "text"); + + expect(rectangles).toHaveLength(6); + expect(texts).toHaveLength(6); + expect(elements.every((element) => element.groupIds[0] === "table-group")).toBe( + true, + ); + expect(rectangles.every((element) => element.width > 0)).toBe(true); + expect(texts.some((element) => element.originalText === "Alice")).toBe(true); + }); +});