From 4d2e44c87851c9d7368d2a08389b6610b30555f1 Mon Sep 17 00:00:00 2001 From: Ilya Bogdanov Date: Tue, 3 Dec 2024 19:22:15 +0400 Subject: [PATCH] Table.input for pasting tabular data (#11695) Closes #11350 - Copy/pasting tabular data now creates `Table.input` nodes. - Column names are always copied when you work inside Enso (excluding cases when you paste some cells into an existing table) - When working with external apps, column names are copied only if `Copy with headers` is selected. https://github.com/user-attachments/assets/a1233483-ee4a-47e4-84a1-64dd0b1505ef Roundtrip with Google Spreadsheets (shows non-trivial TSV data that includes quotes and newlines): https://github.com/user-attachments/assets/4ac662a2-809f-423a-9e47-628f46f92835 --- CHANGELOG.md | 2 + .../utilities/data/__tests__/array.test.ts | 31 +++++++ app/common/src/utilities/data/array.ts | 7 ++ app/common/src/utilities/data/iter.ts | 8 ++ .../project-view/tableVisualisation.spec.ts | 52 ++++++++++-- app/gui/package.json | 2 + .../project-view/components/GraphEditor.vue | 6 +- .../GraphEditor/__tests__/clipboard.test.ts | 31 ------- .../__tests__/clipboardTestCases.json | 2 +- .../components/GraphEditor/clipboard.ts | 18 ++--- .../__tests__/tableParsing.test.ts | 54 +++++++++++++ .../WidgetTableEditor/tableInputArgument.ts | 4 +- .../widgets/WidgetTableEditor/tableParsing.ts | 72 +++++++++++++++++ .../components/shared/AgGridTableView.vue | 80 ++++++++++++++++--- .../visualizations/TableVisualization.vue | 7 +- pnpm-lock.yaml | 18 +++++ 16 files changed, 325 insertions(+), 69 deletions(-) create mode 100644 app/common/src/utilities/data/__tests__/array.test.ts create mode 100644 app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableParsing.test.ts create mode 100644 app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableParsing.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54582535dd..691e15e84c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - [New design for vector-editing widget][11620]. - [Default values on widgets are displayed in italic][11666]. - [Fixed bug causing Table Visualization to show wrong data][11684]. +- [Pasting tabular data now creates Table.input expressions][11695]. - [No halo is displayed around components when hovering][11715]. - [The hover area of the component output port extended twice its size][11715]. @@ -70,6 +71,7 @@ [11666]: https://github.com/enso-org/enso/pull/11666 [11690]: https://github.com/enso-org/enso/pull/11690 [11684]: https://github.com/enso-org/enso/pull/11684 +[11695]: https://github.com/enso-org/enso/pull/11695 [11715]: https://github.com/enso-org/enso/pull/11715 #### Enso Standard Library diff --git a/app/common/src/utilities/data/__tests__/array.test.ts b/app/common/src/utilities/data/__tests__/array.test.ts new file mode 100644 index 0000000000..ac236d8c67 --- /dev/null +++ b/app/common/src/utilities/data/__tests__/array.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from 'vitest' +import * as array from '../array' + +interface TransposeCase { + matrix: number[][] + expected: number[][] +} + +const transposeCases: TransposeCase[] = [ + { matrix: [], expected: [] }, + { matrix: [[]], expected: [[]] }, + { matrix: [[1]], expected: [[1]] }, + { matrix: [[1, 2]], expected: [[1], [2]] }, + { matrix: [[1], [2]], expected: [[1, 2]] }, + { + matrix: [ + [1, 2, 3], + [4, 5, 6], + ], + expected: [ + [1, 4], + [2, 5], + [3, 6], + ], + }, +] + +test.each(transposeCases)('transpose: case %#', ({ matrix, expected }) => { + const transposed = array.transpose(matrix) + expect(transposed).toStrictEqual(expected) +}) diff --git a/app/common/src/utilities/data/array.ts b/app/common/src/utilities/data/array.ts index 392f7cdc37..032937f468 100644 --- a/app/common/src/utilities/data/array.ts +++ b/app/common/src/utilities/data/array.ts @@ -77,3 +77,10 @@ export function spliceAfter(array: T[], items: T[], predicate: (value: T) => export function splicedAfter(array: T[], items: T[], predicate: (value: T) => boolean) { return spliceAfter(Array.from(array), items, predicate) } + +/** Transpose the matrix. */ +export function transpose(matrix: T[][]): T[][] { + if (matrix.length === 0) return [] + if (matrix[0] && matrix[0].length === 0) return [[]] + return matrix[0]!.map((_, colIndex) => matrix.map(row => row[colIndex]!)) +} diff --git a/app/common/src/utilities/data/iter.ts b/app/common/src/utilities/data/iter.ts index d8b44cd541..1bc89027c2 100644 --- a/app/common/src/utilities/data/iter.ts +++ b/app/common/src/utilities/data/iter.ts @@ -218,3 +218,11 @@ export function last(iter: Iterable): T | undefined { for (const el of iter) last = el return last } + +/** Yields items of the iterable with their index. */ +export function* enumerate(items: Iterable): Generator<[T, number]> { + let index = 0 + for (const item of items) { + yield [item, index++] + } +} diff --git a/app/gui/integration-test/project-view/tableVisualisation.spec.ts b/app/gui/integration-test/project-view/tableVisualisation.spec.ts index 641ee13cf0..cc9f51f80a 100644 --- a/app/gui/integration-test/project-view/tableVisualisation.spec.ts +++ b/app/gui/integration-test/project-view/tableVisualisation.spec.ts @@ -1,7 +1,7 @@ -import { test, type Page } from '@playwright/test' +import { test, type Locator, type Page } from '@playwright/test' import * as actions from './actions' import { expect } from './customExpect' -import { mockExpressionUpdate } from './expressionUpdates' +import { mockExpressionUpdate, mockMethodCallInfo } from './expressionUpdates' import { CONTROL_KEY } from './keyboard' import * as locate from './locate' import { graphNodeByBinding } from './locate' @@ -33,7 +33,7 @@ test('Load Table Visualisation', async ({ page }) => { await expect(tableVisualization).toContainText('3,0') }) -test('Copy from Table Visualization', async ({ page, context }) => { +test('Copy/paste from Table Visualization', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']) await actions.goToGraph(page) @@ -44,16 +44,28 @@ test('Copy from Table Visualization', async ({ page, context }) => { await page.mouse.down() await tableVisualization.getByText('2,1').hover() await page.mouse.up() + + // Copy from table visualization await page.keyboard.press(`${CONTROL_KEY}+C`) + let clipboardContent = await page.evaluate(() => window.navigator.clipboard.readText()) + expect(clipboardContent).toMatch(/^0,0\t0,1\r\n1,0\t1,1\r\n2,0\t2,1$/) // Paste to Node. await actions.clickAtBackground(page) const nodesCount = await locate.graphNode(page).count() await page.keyboard.press(`${CONTROL_KEY}+V`) await expect(locate.graphNode(page)).toHaveCount(nodesCount + 1) - await expect(locate.graphNode(page).last().locator('input')).toHaveValue( - '0,0\t0,11,0\t1,12,0\t2,1', - ) + // Node binding would be `node1` for pasted node. + const nodeBinding = 'node1' + await mockMethodCallInfo(page, nodeBinding, { + methodPointer: { + module: 'Standard.Table.Table', + definedOnType: 'Standard.Table.Table.Table', + name: 'input', + }, + notAppliedArguments: [], + }) + await expectTableInputContent(page, locate.graphNode(page).last()) // Paste to Table Widget. const node = await actions.createTableNode(page) @@ -62,6 +74,32 @@ test('Copy from Table Visualization', async ({ page, context }) => { await widget.getByRole('button', { name: 'Add new column' }).click() await widget.locator('.valueCell').first().click() await page.keyboard.press(`${CONTROL_KEY}+V`) + await expectTableInputContent(page, node) + + // Copy from table input widget + await node.getByText('0,0').hover() + await page.mouse.down() + await node.getByText('2,1').hover() + await page.mouse.up() + await page.keyboard.press(`${CONTROL_KEY}+C`) + clipboardContent = await page.evaluate(() => window.navigator.clipboard.readText()) + expect(clipboardContent).toMatch(/^0,0\t0,1\r\n1,0\t1,1\r\n2,0\t2,1$/) + + // Copy from table input widget with headers + await node.getByText('0,0').hover() + await page.mouse.down() + await node.getByText('2,1').hover() + await page.mouse.up() + await page.mouse.down({ button: 'right' }) + await page.mouse.up({ button: 'right' }) + await page.getByText('Copy with Headers').click() + clipboardContent = await page.evaluate(() => window.navigator.clipboard.readText()) + expect(clipboardContent).toMatch(/^Column #1\tColumn #2\r\n0,0\t0,1\r\n1,0\t1,1\r\n2,0\t2,1$/) +}) + +async function expectTableInputContent(page: Page, node: Locator) { + const widget = node.locator('.WidgetTableEditor') + await expect(widget).toBeVisible({ timeout: 5000 }) await expect(widget.locator('.valueCell')).toHaveText([ '0,0', '0,1', @@ -72,4 +110,4 @@ test('Copy from Table Visualization', async ({ page, context }) => { '', '', ]) -}) +} diff --git a/app/gui/package.json b/app/gui/package.json index fcaf938c4e..4a63994178 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -55,6 +55,7 @@ "ajv": "^8.12.0", "amazon-cognito-identity-js": "6.3.6", "clsx": "^2.1.1", + "papaparse": "^5.4.1", "enso-common": "workspace:*", "framer-motion": "11.3.0", "idb-keyval": "^6.2.1", @@ -144,6 +145,7 @@ "@storybook/vue3-vite": "^8.4.2", "@tanstack/react-query-devtools": "5.45.1", "@types/node": "^22.9.0", + "@types/papaparse": "^5.3.15", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "@types/validator": "^13.11.7", diff --git a/app/gui/src/project-view/components/GraphEditor.vue b/app/gui/src/project-view/components/GraphEditor.vue index dfaa71c501..7247ea9a2d 100644 --- a/app/gui/src/project-view/components/GraphEditor.vue +++ b/app/gui/src/project-view/components/GraphEditor.vue @@ -277,8 +277,10 @@ const { scheduleCreateNode, createNodes, placeNode } = provideNodeCreation( toRef(graphNavigator, 'sceneMousePos'), (nodes) => { clearFocus() - nodeSelection.setSelection(nodes) - panToSelected() + if (nodes.size > 0) { + nodeSelection.setSelection(nodes) + panToSelected() + } }, ) diff --git a/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts b/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts index 691513ae7d..22a91d82c1 100644 --- a/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts +++ b/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts @@ -3,7 +3,6 @@ import { isSpreadsheetTsv, nodesFromClipboardContent, nodesToClipboardData, - tsvTableToEnsoExpression, } from '@/components/GraphEditor/clipboard' import { type Node } from '@/stores/graph' import { Ast } from '@/util/ast' @@ -13,36 +12,6 @@ import { expect, test } from 'vitest' import { assertDefined } from 'ydoc-shared/util/assert' import { type VisualizationMetadata } from 'ydoc-shared/yjsModel' -test.each([ - { - description: 'Unpaired surrogate', - tableData: '𝌆\t\uDAAA', - expectedEnsoExpression: "'𝌆\\t\\u{daaa}'.to Table", - }, - { - description: 'Multiple rows, empty cells', - tableData: [ - '\t36\t52', - '11\t\t4.727272727', - '12\t\t4.333333333', - '13\t2.769230769\t4', - '14\t2.571428571\t3.714285714', - '15\t2.4\t3.466666667', - '16\t2.25\t3.25', - '17\t2.117647059\t3.058823529', - '19\t1.894736842\t2.736842105', - '21\t1.714285714\t2.476190476', - '24\t1.5\t2.166666667', - '27\t1.333333333\t1.925925926', - '30\t1.2\t', - ].join('\n'), - expectedEnsoExpression: - "'\\t36\\t52\\n11\\t\\t4.727272727\\n12\\t\\t4.333333333\\n13\\t2.769230769\\t4\\n14\\t2.571428571\\t3.714285714\\n15\\t2.4\\t3.466666667\\n16\\t2.25\\t3.25\\n17\\t2.117647059\\t3.058823529\\n19\\t1.894736842\\t2.736842105\\n21\\t1.714285714\\t2.476190476\\n24\\t1.5\\t2.166666667\\n27\\t1.333333333\\t1.925925926\\n30\\t1.2\\t'.to Table", - }, -])('Enso expression from Excel data: $description', ({ tableData, expectedEnsoExpression }) => { - expect(tsvTableToEnsoExpression(tableData)).toEqual(expectedEnsoExpression) -}) - class MockClipboardItem { readonly types: ReadonlyArray diff --git a/app/gui/src/project-view/components/GraphEditor/__tests__/clipboardTestCases.json b/app/gui/src/project-view/components/GraphEditor/__tests__/clipboardTestCases.json index a23e9854ab..916408f50f 100644 --- a/app/gui/src/project-view/components/GraphEditor/__tests__/clipboardTestCases.json +++ b/app/gui/src/project-view/components/GraphEditor/__tests__/clipboardTestCases.json @@ -7,7 +7,7 @@ "firefox-127.0": "
f/1.4 R LM WR18
f/1.4 R LM WR23
" }, "plainText": "SEL-20F18G\t20\nSEL-24F28G\t24", - "ensoCode": "'SEL-20F18G\\t20\\nSEL-24F28G\\t24'.to Table" + "ensoCode": "Table.input [['Column #1', ['SEL-20F18G', 'SEL-24F28G']], ['Column #2', ['20', '24']]]" }, { "spreadsheet": "Excel", diff --git a/app/gui/src/project-view/components/GraphEditor/clipboard.ts b/app/gui/src/project-view/components/GraphEditor/clipboard.ts index 602db40c72..14e9acff01 100644 --- a/app/gui/src/project-view/components/GraphEditor/clipboard.ts +++ b/app/gui/src/project-view/components/GraphEditor/clipboard.ts @@ -1,12 +1,10 @@ import type { NodeCreationOptions } from '@/composables/nodeCreation' import type { Node } from '@/stores/graph' -import { Ast } from '@/util/ast' -import { Pattern } from '@/util/ast/match' import { nodeDocumentationText } from '@/util/ast/node' import { Vec2 } from '@/util/data/vec2' import * as iter from 'enso-common/src/utilities/data/iter' -import { computed } from 'vue' import type { NodeMetadataFields } from 'ydoc-shared/ast' +import { parseTsvData, tableToEnsoExpression } from './widgets/WidgetTableEditor/tableParsing' // MIME type in *vendor tree*; see https://www.rfc-editor.org/rfc/rfc6838#section-3.2 // The `web ` prefix is required by Chromium: @@ -133,19 +131,15 @@ const spreadsheetDecoder: ClipboardDecoder = { if (!item.types.includes('text/plain')) return if (isSpreadsheetTsv(htmlContent)) { const textData = await item.getType('text/plain').then((blob) => blob.text()) - return [{ expression: tsvTableToEnsoExpression(textData) }] + const rows = parseTsvData(textData) + if (rows == null) return + const expression = tableToEnsoExpression(rows) + if (expression == null) return + return [{ expression }] } }, } -const toTable = computed(() => Pattern.parseExpression('__.to Table')) - -/** Create Enso Expression generating table from this tsvData. */ -export function tsvTableToEnsoExpression(tsvData: string) { - const textLiteral = Ast.TextLiteral.new(tsvData) - return toTable.value.instantiate(textLiteral.module, [textLiteral]).code() -} - /** @internal */ export function isSpreadsheetTsv(htmlContent: string) { // This is a very general criterion that can have some false-positives (e.g. pasting rich text that includes a table). diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableParsing.test.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableParsing.test.ts new file mode 100644 index 0000000000..0769d5906c --- /dev/null +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableParsing.test.ts @@ -0,0 +1,54 @@ +import { assert } from '@/util/assert' +import { expect, test } from 'vitest' +import { parseTsvData, tableToEnsoExpression } from '../tableParsing' + +test.each([ + { + description: 'Unpaired surrogate', + tableData: '𝌆\t\uDAAA', + expectedEnsoExpression: "Table.input [['Column #1', ['𝌆']], ['Column #2', ['\\u{daaa}']]]", + }, + { + description: 'Empty cell', + tableData: '1\t2\n3\t', + expectedEnsoExpression: + "Table.input [['Column #1', ['1', '3']], ['Column #2', ['2', Nothing]]]", + }, + { + description: 'Line feed in cell', + tableData: '1\t"2\n3"\n4\t5', + expectedEnsoExpression: + "Table.input [['Column #1', ['1', '4']], ['Column #2', ['2\\n3', '5']]]", + }, + { + description: 'Line feed in quoted cell', + tableData: '1\t4\n2\t"""5\n6"""', + expectedEnsoExpression: + "Table.input [['Column #1', ['1', '2']], ['Column #2', ['4', '\"5\\n6\"']]]", + }, + { + description: 'Multiple rows, empty cells', + tableData: [ + '\t36\t52', + '11\t\t4.727272727', + '12\t\t4.333333333', + '13\t2.769230769\t4', + '14\t2.571428571\t3.714285714', + '15\t2.4\t3.466666667', + '16\t2.25\t3.25', + '17\t2.117647059\t3.058823529', + '19\t1.894736842\t2.736842105', + '21\t1.714285714\t2.476190476', + '24\t1.5\t2.166666667', + '27\t1.333333333\t1.925925926', + '30\t1.2\t', + ].join('\n'), + expectedEnsoExpression: + "Table.input [['Column #1', [Nothing, '11', '12', '13', '14', '15', '16', '17', '19', '21', '24', '27', '30']], ['Column #2', ['36', Nothing, Nothing, '2.769230769', '2.571428571', '2.4', '2.25', '2.117647059', '1.894736842', '1.714285714', '1.5', '1.333333333', '1.2']], ['Column #3', ['52', '4.727272727', '4.333333333', '4', '3.714285714', '3.466666667', '3.25', '3.058823529', '2.736842105', '2.476190476', '2.166666667', '1.925925926', Nothing]]]", + }, +])('Enso expression from Excel data: $description', ({ tableData, expectedEnsoExpression }) => { + const rows = parseTsvData(tableData) + expect(rows).not.toBeNull() + assert(rows != null) + expect(tableToEnsoExpression(rows)).toEqual(expectedEnsoExpression) +}) diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts index f007d07082..37f92833fc 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts @@ -21,7 +21,7 @@ export const ROW_INDEX_HEADER = '#' /** A default prefix added to the column's index in newly created columns. */ export const DEFAULT_COLUMN_PREFIX = 'Column #' const NOTHING_PATH = 'Standard.Base.Nothing.Nothing' as QualifiedName -const NOTHING_NAME = qnLastSegment(NOTHING_PATH) +export const NOTHING_NAME = qnLastSegment(NOTHING_PATH) as Ast.Identifier /** * The cells limit of the table; any modification which would exceed this limt should be * disallowed in UI @@ -369,7 +369,7 @@ export function useTableInputArgument( contextMenuItems: [ commonContextMenuActions.cut, commonContextMenuActions.copy, - 'copyWithHeaders', + commonContextMenuActions.copyWithHeaders, commonContextMenuActions.paste, 'separator', removeRowMenuItem, diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableParsing.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableParsing.ts new file mode 100644 index 0000000000..160d86ee4e --- /dev/null +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableParsing.ts @@ -0,0 +1,72 @@ +import { Ast } from '@/util/ast' +import { Pattern } from '@/util/ast/match' +import { transpose } from 'enso-common/src/utilities/data/array' +import * as iter from 'enso-common/src/utilities/data/iter' +import Papa from 'papaparse' +import { computed } from 'vue' +import { DEFAULT_COLUMN_PREFIX, NOTHING_NAME } from './tableInputArgument' + +const toTable = computed(() => Pattern.parseExpression('Table.input __')) + +/** + * Parse data in TSV format (according to RFC 4180). + * @throws if the number of columns in each row is not the same. + * @returns an array of rows, each row is an array of cells. + */ +function parseTsvDataImpl(tsvData: string): string[][] { + const parseResult = Papa.parse(tsvData, { delimiter: '\t', header: false }) + for (const error of parseResult.errors) { + // These errors not necessearily mean that the parsing failed. + // Malformed TSV data is a non-existent beast (?). + console.error('Error parsing TSV data:', error) + } + // The conversion is safe according to the documentation of Papa.parse, as we set `header: false`. + const rows = parseResult.data as string[][] + for (const row of rows) { + if (row.length !== rows[0]!.length) { + throw new Error('All rows must have the same number of columns.') + } + } + return rows +} + +/** + * Parse data in TSV format (according to RFC 4180). Each row has the same number of cells. + * @returns an array of rows, each row is an array of cells, or null if the parsing failed. + */ +export function parseTsvData(tsvData: string): string[][] | null { + try { + return parseTsvDataImpl(tsvData) + } catch (error) { + console.error('Failed to parse TSV data:', error) + return null + } +} + +/** Serialize rows to TSV format. The reverse of {@link parseTsvData}. */ +export function rowsToTsv(rows: string[][]): string { + return Papa.unparse(rows, { delimiter: '\t', newline: '\r\n' }) +} + +/** + * Create `Table.input` expression generating table from the provided rows. + * @param rows - String values to be inserted into the table. + * @param columnNames - Optional column names to be used in the resulting table. + * If not provided, default column names are generated. + */ +export function tableToEnsoExpression(rows: string[][], columnNames?: string[]): string | null { + const table = transpose(rows) + const getColumnName = (index: number) => { + if (columnNames && columnNames[index]) return columnNames[index] + return `${DEFAULT_COLUMN_PREFIX}${index + 1}` + } + const emptyCell = (module: Ast.MutableModule) => Ast.Ident.new(module, NOTHING_NAME) + const columnAst = Ast.Vector.tryBuild(iter.enumerate(table), ([column, index], module) => { + const columnName = Ast.TextLiteral.new(getColumnName(index), module) + const makeCell = (cell: string, module: Ast.MutableModule): Ast.Owned => + cell === '' ? emptyCell(module) : Ast.TextLiteral.new(cell, module) + const values = Ast.Vector.tryBuild(column, makeCell, module) + return Ast.Vector.new(module, [columnName, values]) + }) + return toTable.value.instantiate(columnAst.module, [columnAst]).code() +} diff --git a/app/gui/src/project-view/components/shared/AgGridTableView.vue b/app/gui/src/project-view/components/shared/AgGridTableView.vue index bafa813b2d..9f605492f4 100644 --- a/app/gui/src/project-view/components/shared/AgGridTableView.vue +++ b/app/gui/src/project-view/components/shared/AgGridTableView.vue @@ -1,6 +1,8 @@ @@ -42,11 +65,6 @@ export const commonContextMenuActions = { * Component adding some useful logic to AGGrid table component (like keeping track of colum sizes), * and using common style for tables in our application. */ -import { - clipboardNodeData, - tsvTableToEnsoExpression, - writeClipboard, -} from '@/components/GraphEditor/clipboard' import type { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue' import { useAutoBlur } from '@/util/autoBlur' import type { @@ -68,7 +86,13 @@ import type { } from 'ag-grid-enterprise' import * as iter from 'enso-common/src/utilities/data/iter' import { LINE_BOUNDARIES } from 'enso-common/src/utilities/data/string' -import { type ComponentInstance, reactive, ref, shallowRef, watch } from 'vue' +import { type ComponentInstance, reactive, shallowRef, watch } from 'vue' +import { clipboardNodeData, writeClipboard } from '../GraphEditor/clipboard' +import { + parseTsvData, + rowsToTsv, + tableToEnsoExpression, +} from '../GraphEditor/widgets/WidgetTableEditor/tableParsing' const DEFAULT_ROW_HEIGHT = 22 @@ -177,12 +201,40 @@ function lockColumnSize(e: ColumnResizedEvent) { * content. This data contains a ready-to-paste node that constructs an Enso table from the provided TSV. */ function sendToClipboard({ data }: { data: string }) { + const rows = parseTsvData(data) + if (rows == null) return + // First row of `data` contains column names. + const columnNames = rows[0] + const rowsWithoutHeaders = rows.slice(1) + const expression = tableToEnsoExpression(rowsWithoutHeaders, columnNames) + if (expression == null) return + const clipboardContent = copyWithHeaders.value ? rows : rowsWithoutHeaders return writeClipboard({ - ...clipboardNodeData([{ expression: tsvTableToEnsoExpression(data) }]), - 'text/plain': data, + ...clipboardNodeData([{ expression }]), + 'text/plain': rowsToTsv(clipboardContent), }) } +/** + * AgGrid does not conform RFC 4180 when serializing copied cells to TSV before calling {@link sendToClipboard}. + * We need to escape tabs, newlines and double quotes in the cell values to make + * sure round-trip with Excel and Google Spreadsheet works. + */ +function processCellForClipboard({ + value, + formatValue, +}: { + value: any + formatValue: (arg: any) => string +}) { + if (value == null) return '' + const formatted = formatValue(value) + if (formatted.match(/[\t\n\r"]/)) { + return `"${formatted.replaceAll(/"/g, '""')}"` + } + return formatted +} + defineExpose({ gridApi }) // === Keybinds === @@ -207,7 +259,7 @@ function supressCopy(event: KeyboardEvent) { // and AgGrid API does not allow copy suppression. if ( (event.code === 'KeyX' || event.code === 'KeyC' || event.code === 'KeyV') && - event.ctrlKey && + modKey(event) && wrapper.value != null && event.target != wrapper.value ) { @@ -258,6 +310,8 @@ const { AgGridVue } = await import('ag-grid-vue3') :rowData="rowData" :columnDefs="columnDefs" :defaultColDef="defaultColDef" + :copyHeadersToClipboard="true" + :processCellForClipboard="processCellForClipboard" :sendToClipboard="sendToClipboard" :suppressFieldDotNotation="true" :enableRangeSelection="true" diff --git a/app/gui/src/project-view/components/visualizations/TableVisualization.vue b/app/gui/src/project-view/components/visualizations/TableVisualization.vue index 79af4010ee..356a981ca9 100644 --- a/app/gui/src/project-view/components/visualizations/TableVisualization.vue +++ b/app/gui/src/project-view/components/visualizations/TableVisualization.vue @@ -132,7 +132,12 @@ const defaultColDef: Ref = ref({ minWidth: 25, cellRenderer: cellRenderer, cellClass: cellClass, - contextMenuItems: [commonContextMenuActions.copy, 'copyWithHeaders', 'separator', 'export'], + contextMenuItems: [ + commonContextMenuActions.copy, + commonContextMenuActions.copyWithHeaders, + 'separator', + 'export', + ], } satisfies ColDef) const rowData = ref[]>([]) const columnDefs: Ref = ref([]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad0a15622c..7b557b1bce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,9 @@ importers: murmurhash: specifier: ^2.0.1 version: 2.0.1 + papaparse: + specifier: ^5.4.1 + version: 5.4.1 postcss-inline-svg: specifier: ^6.0.0 version: 6.0.0(postcss@8.4.45) @@ -446,6 +449,9 @@ importers: '@types/node': specifier: ^22.9.0 version: 22.9.0 + '@types/papaparse': + specifier: ^5.3.15 + version: 5.3.15 '@types/react': specifier: ^18.0.27 version: 18.3.3 @@ -3219,6 +3225,9 @@ packages: '@types/opener@1.4.3': resolution: {integrity: sha512-g7TYSmy2RKZkU3QT/9pMISrhVmQtMNaYq6Aojn3Y6pht29Nu9VuijJCYIjofRj7ZaFtKdxh1I8xf3vdW4l86fg==} + '@types/papaparse@5.3.15': + resolution: {integrity: sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -6358,6 +6367,9 @@ packages: package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + papaparse@5.4.1: + resolution: {integrity: sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -11330,6 +11342,10 @@ snapshots: dependencies: '@types/node': 22.9.0 + '@types/papaparse@5.3.15': + dependencies: + '@types/node': 22.9.0 + '@types/plist@3.0.5': dependencies: '@types/node': 22.9.0 @@ -14981,6 +14997,8 @@ snapshots: package-json-from-dist@1.0.0: {} + papaparse@5.4.1: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4