mirror of
https://github.com/enso-org/enso.git
synced 2024-12-19 22:41:49 +03:00
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
This commit is contained in:
parent
78c2068063
commit
4d2e44c878
@ -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
|
||||
|
31
app/common/src/utilities/data/__tests__/array.test.ts
Normal file
31
app/common/src/utilities/data/__tests__/array.test.ts
Normal file
@ -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)
|
||||
})
|
@ -77,3 +77,10 @@ export function spliceAfter<T>(array: T[], items: T[], predicate: (value: T) =>
|
||||
export function splicedAfter<T>(array: T[], items: T[], predicate: (value: T) => boolean) {
|
||||
return spliceAfter(Array.from(array), items, predicate)
|
||||
}
|
||||
|
||||
/** Transpose the matrix. */
|
||||
export function transpose<T>(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]!))
|
||||
}
|
||||
|
@ -218,3 +218,11 @@ export function last<T>(iter: Iterable<T>): T | undefined {
|
||||
for (const el of iter) last = el
|
||||
return last
|
||||
}
|
||||
|
||||
/** Yields items of the iterable with their index. */
|
||||
export function* enumerate<T>(items: Iterable<T>): Generator<[T, number]> {
|
||||
let index = 0
|
||||
for (const item of items) {
|
||||
yield [item, index++]
|
||||
}
|
||||
}
|
||||
|
@ -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 }) => {
|
||||
'',
|
||||
'',
|
||||
])
|
||||
})
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -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<string>
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
"firefox-127.0": "<google-sheets-html-origin><style type=\"text/css\"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns=\"http://www.w3.org/1999/xhtml\" cellspacing=\"0\" cellpadding=\"0\" dir=\"ltr\" border=\"1\" style=\"table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none\" data-sheets-root=\"1\"><colgroup><col width=\"155\"/><col width=\"71\"/></colgroup><tbody><tr style=\"height:21px;\"><td style=\"overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;\" data-sheets-value=\"{"1":2,"2":"f/1.4 R LM WR"}\">f/1.4 R LM WR</td><td style=\"overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;\" data-sheets-value=\"{"1":3,"3":18}\">18</td></tr><tr style=\"height:21px;\"><td style=\"overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;background-color:#ffffff;\" data-sheets-value=\"{"1":2,"2":"f/1.4 R LM WR"}\">f/1.4 R LM WR</td><td style=\"overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;\" data-sheets-value=\"{"1":3,"3":23}\">23</td></tr></tbody></table>"
|
||||
},
|
||||
"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",
|
||||
|
@ -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<CopiedNode[]> = {
|
||||
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).
|
||||
|
@ -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)
|
||||
})
|
@ -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,
|
||||
|
@ -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<Ast.MutableAst> =>
|
||||
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()
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { gridBindings } from '@/bindings'
|
||||
import { modKey } from '@/composables/events'
|
||||
import type { MenuItemDef } from 'ag-grid-enterprise'
|
||||
import { ref } from 'vue'
|
||||
/**
|
||||
* A more specialized version of AGGrid's `MenuItemDef` to simplify testing (the tests need to provide
|
||||
* only values actually used by the composable)
|
||||
@ -14,25 +16,46 @@ export interface MenuItem<TData> extends MenuItemDef<TData> {
|
||||
|
||||
const AGGRID_DEFAULT_COPY_ICON =
|
||||
'<span class="ag-icon ag-icon-copy" unselectable="on" role="presentation"></span>'
|
||||
const AGGRID_DEFAULT_CUT_ICON =
|
||||
'<span class="ag-icon ag-icon-cut" unselectable="on" role="presentation"></span>'
|
||||
const AGGRID_DEFAULT_PASTE_ICON =
|
||||
'<span class="ag-icon ag-icon-paste" unselectable="on" role="presentation"></span>'
|
||||
|
||||
/** Whether to include column headers in copied clipboard content or not. See {@link sendToClipboard}. */
|
||||
const copyWithHeaders = ref(false)
|
||||
|
||||
export const commonContextMenuActions = {
|
||||
cut: {
|
||||
name: 'Cut',
|
||||
shortcut: gridBindings.bindings['cutCells'].humanReadable,
|
||||
action: ({ api }) => api.cutToClipboard(),
|
||||
icon: AGGRID_DEFAULT_COPY_ICON,
|
||||
action: ({ api }) => {
|
||||
copyWithHeaders.value = false
|
||||
api.cutToClipboard()
|
||||
},
|
||||
icon: AGGRID_DEFAULT_CUT_ICON,
|
||||
},
|
||||
copy: {
|
||||
name: 'Copy',
|
||||
shortcut: gridBindings.bindings['copyCells'].humanReadable,
|
||||
action: ({ api }) => api.copyToClipboard(),
|
||||
action: ({ api }) => {
|
||||
copyWithHeaders.value = false
|
||||
api.copyToClipboard()
|
||||
},
|
||||
icon: AGGRID_DEFAULT_COPY_ICON,
|
||||
},
|
||||
copyWithHeaders: {
|
||||
name: 'Copy with Headers',
|
||||
action: ({ api }) => {
|
||||
copyWithHeaders.value = true
|
||||
api.copyToClipboard()
|
||||
},
|
||||
icon: AGGRID_DEFAULT_COPY_ICON,
|
||||
},
|
||||
paste: {
|
||||
name: 'Paste',
|
||||
shortcut: gridBindings.bindings['pasteCells'].humanReadable,
|
||||
action: ({ api }) => api.pasteFromClipboard(),
|
||||
icon: AGGRID_DEFAULT_COPY_ICON,
|
||||
icon: AGGRID_DEFAULT_PASTE_ICON,
|
||||
},
|
||||
} satisfies Record<string, MenuItem<unknown>>
|
||||
</script>
|
||||
@ -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"
|
||||
|
@ -132,7 +132,12 @@ const defaultColDef: Ref<ColDef> = 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<Record<string, any>[]>([])
|
||||
const columnDefs: Ref<ColDef[]> = ref([])
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user