diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0b361e0e709b..a70a3c536b79 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 { parseTableData, createTableHtml, getTableDimensions } from "../table"; import ConvertElementTypePopup, { getConversionTypeFromElements, @@ -1637,6 +1638,19 @@ class App extends React.Component { }, } as const; } + } else if ( + isEmbeddableElement(el) && + el.customData?.tableData && + Array.isArray(el.customData.tableData) + ) { + const cells = el.customData.tableData as string[][]; + src = { + intrinsicSize: { w: el.width, h: el.height }, + type: "document", + srcdoc: () => + createTableHtml(cells, this.state.theme as "light" | "dark"), + sandbox: { allowSameOrigin: true }, + } as const; } else { src = getEmbedLink(toValidURL(el.link || "")); } @@ -3546,15 +3560,37 @@ class App extends React.Component { return; } - // ------------------- Spreadsheet ------------------- + // ------------------- Spreadsheet / Table ------------------- if (!isPlainPaste && data.text) { - const result = tryParseSpreadsheet(data.text); - if (result.ok) { + const tableCells = parseTableData(data.text); + const chartResult = tryParseSpreadsheet(data.text); + + if (tableCells && tableCells.length > 0) { + if (chartResult.ok) { + this.setState({ + openDialog: { + name: "charts", + data: chartResult.data, + rawText: data.text, + tableCells, + }, + }); + } else { + this.setState({ + openDialog: { + name: "table", + cells: tableCells, + rawText: data.text, + }, + }); + } + return; + } else if (chartResult.ok) { this.setState({ openDialog: { name: "charts", - data: result.data, + data: chartResult.data, rawText: data.text, }, }); @@ -8552,6 +8588,61 @@ class App extends React.Component { return element; }; + public insertTableElement = ({ + sceneX, + sceneY, + cells, + }: { + sceneX?: number; + sceneY?: number; + cells: string[][]; + }) => { + const { x: cursorX, y: cursorY } = viewportCoordsToSceneCoords( + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, + this.state, + ); + const x = sceneX ?? cursorX; + const y = sceneY ?? cursorY; + const [gridX, gridY] = getGridPoint( + x, + y, + this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD] + ? null + : this.getEffectiveGridSize(), + ); + + const { width, height } = getTableDimensions( + cells.length, + cells[0]?.length ?? 0, + ); + + const element = newEmbeddableElement({ + type: "embeddable", + x: gridX, + y: gridY, + strokeColor: this.state.currentItemStrokeColor, + backgroundColor: this.state.currentItemBackgroundColor, + fillStyle: this.state.currentItemFillStyle, + strokeWidth: this.state.currentItemStrokeWidth, + strokeStyle: this.state.currentItemStrokeStyle, + roughness: this.state.currentItemRoughness, + roundness: this.getCurrentItemRoundness("embeddable"), + opacity: this.state.currentItemOpacity, + locked: false, + width, + height, + link: "excalidraw-table:", + customData: { tableData: cells }, + }); + + this.scene.insertElement(element); + + return element; + }; + //create rectangle element with youtube top left on nearest grid point width / hight 640/360 public insertEmbeddableElement = ({ sceneX, @@ -11563,6 +11654,32 @@ class App extends React.Component { if (fileItems.length > 0) { const { file, fileHandle } = fileItems[0]; if (file) { + const isCsvFile = + file.name?.toLowerCase().endsWith(".csv") || + file.type === "text/csv" || + file.type === "application/csv"; + if (isCsvFile) { + try { + const text = await file.text(); + const cells = parseTableData(text); + if (cells && cells.length > 0) { + const tableElement = this.insertTableElement({ + sceneX, + sceneY, + cells, + }); + if (tableElement) { + this.store.scheduleCapture(); + this.setState({ + selectedElementIds: { [tableElement.id]: true }, + }); + } + return; + } + } catch (error: any) { + console.warn("Failed to parse CSV file:", error); + } + } // Attempt to parse an excalidraw/excalidrawlib file await this.loadFileToCanvas(file, fileHandle); } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 85d2701b1142..5f155e368179 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -36,6 +36,7 @@ import { LoadingMessage } from "./LoadingMessage"; import { LockButton } from "./LockButton"; import { MobileMenu } from "./MobileMenu"; import { PasteChartDialog } from "./PasteChartDialog"; +import { PasteTableDialog } from "./PasteTableDialog"; import { Section } from "./Section"; import Stack from "./Stack"; import { UserList } from "./UserList"; @@ -560,6 +561,18 @@ const LayerUI = ({ + setAppState({ + openDialog: null, + }) + } + /> + )} + {appState.openDialog?.name === "table" && ( + setAppState({ openDialog: null, diff --git a/packages/excalidraw/components/PasteChartDialog.scss b/packages/excalidraw/components/PasteChartDialog.scss index 7e73768e39b0..ba9af5900f30 100644 --- a/packages/excalidraw/components/PasteChartDialog.scss +++ b/packages/excalidraw/components/PasteChartDialog.scss @@ -102,5 +102,32 @@ min-height: 200px; } } + .ChartPreview__table-preview, + .ChartPreview__plaintext-preview { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + padding: 0.5rem; + overflow: hidden; + } + .ChartPreview__table-preview { + table { + border-collapse: collapse; + font-size: 0.7rem; + } + td { + border: 1px solid $color-gray-4; + padding: 2px 4px; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + } + } + .ChartPreview__plaintext-preview { + white-space: pre-wrap; + word-break: break-word; + text-align: left; + } } } diff --git a/packages/excalidraw/components/PasteChartDialog.tsx b/packages/excalidraw/components/PasteChartDialog.tsx index 19c32717669e..58b7c46e66c6 100644 --- a/packages/excalidraw/components/PasteChartDialog.tsx +++ b/packages/excalidraw/components/PasteChartDialog.tsx @@ -176,13 +176,15 @@ const PlainTextPreviewBtn = (props: { export const PasteChartDialog = ({ data, rawText, + tableCells, onClose, }: { data: Spreadsheet; rawText: string; + tableCells?: string[][]; onClose: () => void; }) => { - const { onInsertElements, focusContainer } = useApp(); + const { onInsertElements, insertTableElement, focusContainer } = useApp(); const [colorSeed, setColorSeed] = useState(Math.random()); const handleReshuffleColors = React.useCallback(() => { @@ -214,6 +216,15 @@ export const PasteChartDialog = ({ focusContainer(); }; + const handleTableClick = () => { + if (tableCells && tableCells.length > 0) { + insertTableElement({ cells: tableCells }); + trackEvent("paste", "chart", "table"); + onClose(); + focusContainer(); + } + }; + return ( ); })} + {tableCells && tableCells.length > 0 && ( + + )} {rawText && ( void; +}) => { + const { insertTableElement, onInsertElements, focusContainer } = useApp(); + + const handleClose = React.useCallback(() => { + if (onClose) { + onClose(); + } + }, [onClose]); + + const handleTableClick = () => { + insertTableElement({ cells }); + trackEvent("paste", "table", "table"); + onClose(); + focusContainer(); + }; + + const handlePlainTextClick = () => { + const textElement = newTextElement({ + text: rawText, + x: 0, + y: 0, + }); + onInsertElements([textElement]); + trackEvent("paste", "table", "plaintext"); + onClose(); + focusContainer(); + }; + + return ( + +
+ + +
+
+ ); +}; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 7a2a7294f8a5..778392b5e169 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -7,6 +7,7 @@ "chartType_line": "Line chart", "chartType_radar": "Radar chart", "chartType_plaintext": "Plain text", + "chartType_table": "Table", "selectAll": "Select all", "multiSelect": "Add element to selection", "moveCanvas": "Move canvas", diff --git a/packages/excalidraw/table/index.ts b/packages/excalidraw/table/index.ts new file mode 100644 index 000000000000..10cde3c33ac8 --- /dev/null +++ b/packages/excalidraw/table/index.ts @@ -0,0 +1,9 @@ +export { + parseTableData, + createTableHtml, + getTableDimensions, + TABLE_CELL_WIDTH, + TABLE_CELL_HEIGHT, + TABLE_HEADER_HEIGHT, + TABLE_PADDING, +} from "./table"; diff --git a/packages/excalidraw/table/table.ts b/packages/excalidraw/table/table.ts new file mode 100644 index 000000000000..ee3239b22484 --- /dev/null +++ b/packages/excalidraw/table/table.ts @@ -0,0 +1,141 @@ +/** + * Parses tabular data from CSV, TSV, or similar delimited text. + * Returns a 2D array of cell values, or null if parsing fails. + */ +export const parseTableData = (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 isTable = lines.every((line) => line.length === numColsFirstLine); + + if (!isTable) { + return null; + } + + return lines; +}; + +export const TABLE_CELL_WIDTH = 120; +export const TABLE_CELL_HEIGHT = 32; +export const TABLE_HEADER_HEIGHT = 36; +export const TABLE_PADDING = 8; + +/** + * Calculates dimensions for a table based on cell count. + */ +export const getTableDimensions = ( + rows: number, + cols: number, +): { width: number; height: number } => { + return { + width: cols * TABLE_CELL_WIDTH + TABLE_PADDING * 2, + height: TABLE_HEADER_HEIGHT + rows * TABLE_CELL_HEIGHT + TABLE_PADDING * 2, + }; +}; + +/** + * Creates an HTML string for an editable table. + */ +export const createTableHtml = ( + cells: string[][], + theme: "light" | "dark", +): string => { + const isDark = theme === "dark"; + const bg = isDark ? "#1b1913" : "#f7f7f4"; + const headerBg = isDark ? "#26241e" : "#e6e5e0"; + const borderColor = isDark ? "#3d3b35" : "#d4d3ce"; + const textColor = isDark ? "#edecec" : "#26251e"; + + const rows = cells + .map((row, rowIndex) => { + const tag = rowIndex === 0 ? "th" : "td"; + const cellStyle = rowIndex === 0 ? `background:${headerBg};` : ""; + const cellsHtml = row + .map( + (cell) => + `<${tag} style="${cellStyle}border:1px solid ${borderColor};padding:6px 8px;color:${textColor};" contenteditable="true">${escapeHtml( + cell, + )}`, + ) + .join(""); + return `${cellsHtml}`; + }) + .join(""); + + return ` + + + + + + + + + ${rows} +
+ +`; +}; + +const escapeHtml = (str: string): string => { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +}; diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c81459c1c40d..2af74396ecf3 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -12622,7 +12622,15 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "objectsSnapModeEnabled": false, "offsetLeft": 0, "offsetTop": 0, - "openDialog": null, + "openDialog": { + "cells": [ + [ + "https://www.youtube.com/watch?v=gkGMXY0wekg", + ], + ], + "name": "table", + "rawText": "https://www.youtube.com/watch?v=gkGMXY0wekg", + }, "openMenu": null, "openPopup": null, "openSidebar": null, @@ -12638,9 +12646,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e "scrollX": 0, "scrollY": 0, "searchMatches": null, - "selectedElementIds": { - "id0": true, - }, + "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, "selectionElement": null, @@ -12667,98 +12673,13 @@ exports[`history > singleplayer undo/redo > should create new history entry on e } `; -exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] element 0 1`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 315, - "id": "id0", - "index": "a0", - "isDeleted": false, - "link": "https://www.youtube.com/watch?v=gkGMXY0wekg", - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "strokeColor": "transparent", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "embeddable", - "updated": 1, - "version": 4, - "width": 560, - "x": 0, - "y": 0, -} -`; - -exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] number of elements 1`] = `1`; +exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] number of elements 1`] = `0`; -exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] number of renders 1`] = `7`; +exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] number of renders 1`] = `4`; exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] redo stack 1`] = `[]`; -exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] undo stack 1`] = ` -[ - { - "appState": AppStateDelta { - "delta": Delta { - "deleted": { - "selectedElementIds": { - "id0": true, - }, - }, - "inserted": { - "selectedElementIds": {}, - }, - }, - }, - "elements": { - "added": {}, - "removed": { - "id0": { - "deleted": { - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 315, - "index": "a0", - "isDeleted": false, - "link": "https://www.youtube.com/watch?v=gkGMXY0wekg", - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "strokeColor": "transparent", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "embeddable", - "version": 4, - "width": 560, - "x": 0, - "y": 0, - }, - "inserted": { - "isDeleted": true, - "version": 3, - }, - }, - }, - "updated": {}, - }, - "id": "id4", - }, -] -`; +exports[`history > singleplayer undo/redo > should create new history entry on embeddable link paste > [end of test] undo stack 1`] = `[]`; exports[`history > singleplayer undo/redo > should create new history entry on image drag&drop > [end of test] appState 1`] = ` { diff --git a/packages/excalidraw/tests/fixtures/test.csv b/packages/excalidraw/tests/fixtures/test.csv new file mode 100644 index 000000000000..e93ff43f6b60 --- /dev/null +++ b/packages/excalidraw/tests/fixtures/test.csv @@ -0,0 +1,5 @@ +id, first_name, last_name +1, Alice, Awtood +2, Bob, Billby +3, Charlie, Cookies +4, Darla, Darling \ No newline at end of file diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index ac185ab56390..e3cb78eac3ae 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -392,7 +392,13 @@ export interface AppState { | { name: "commandPalette" } | { name: "settings" } | { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] } - | { name: "charts"; data: Spreadsheet; rawText: string }; + | { + name: "charts"; + data: Spreadsheet; + rawText: string; + tableCells?: string[][]; + } + | { name: "table"; cells: string[][]; rawText: string }; /** * Reflects user preference for whether the default sidebar should be docked. * @@ -749,6 +755,7 @@ export type AppClassProperties = { setActiveTool: App["setActiveTool"]; setOpenDialog: App["setOpenDialog"]; insertEmbeddableElement: App["insertEmbeddableElement"]; + insertTableElement: App["insertTableElement"]; onMagicframeToolSelect: App["onMagicframeToolSelect"]; getName: App["getName"]; dismissLinearEditor: App["dismissLinearEditor"];