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:
Ilya Bogdanov 2024-12-03 19:22:15 +04:00 committed by GitHub
parent 78c2068063
commit 4d2e44c878
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 325 additions and 69 deletions

View File

@ -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

View 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)
})

View File

@ -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]!))
}

View File

@ -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++]
}
}

View File

@ -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 }) => {
'',
'',
])
})
}

View File

@ -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",

View File

@ -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()
}
},
)

View File

@ -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>

View File

@ -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=\"{&quot;1&quot;:2,&quot;2&quot;:&quot;f/1.4 R LM WR&quot;}\">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=\"{&quot;1&quot;:3,&quot;3&quot;: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=\"{&quot;1&quot;:2,&quot;2&quot;:&quot;f/1.4 R LM WR&quot;}\">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=\"{&quot;1&quot;:3,&quot;3&quot;: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",

View File

@ -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).

View File

@ -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)
})

View File

@ -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,

View File

@ -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()
}

View File

@ -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"

View File

@ -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([])

View File

@ -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