Skip to content

Commit

Permalink
Copied table-viz range pastes as Table component (#10352)
Browse files Browse the repository at this point in the history
When copying from AG Grid, include additional information in the clipboard to enable the data to be pasted into the graph as a new Table component.

Closes #10275.

# Important Notes
- Data copied from the table visualization now include headers.

(cherry picked from commit da21136)
  • Loading branch information
kazcw authored and jdunkerley committed Jul 2, 2024
1 parent 201eec3 commit e7bc071
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 49 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
disallowed changing it again.][10337]
- [Added click through on table and vector visualisation][10340] clicking on
index column will select row or value in seperate node
- [Copied table-viz range pastes as Table component][10352]
- [Added support for links in documentation panels][10353].

[10064]: https://github.com/enso-org/enso/pull/10064
Expand All @@ -35,6 +36,7 @@
[10327]: https://github.com/enso-org/enso/pull/10327
[10337]: https://github.com/enso-org/enso/pull/10337
[10340]: https://github.com/enso-org/enso/pull/10340
[10352]: https://github.com/enso-org/enso/pull/10352
[10353]: https://github.com/enso-org/enso/pull/10353

#### Enso Standard Library
Expand Down
12 changes: 4 additions & 8 deletions app/gui2/src/components/GraphEditor/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
isSpreadsheetTsv,
nodesFromClipboardContent,
nodesToClipboardData,
tsvToEnsoTable,
tsvTableToEnsoExpression,
} from '@/components/GraphEditor/clipboard'
import { type Node } from '@/stores/graph'
import { Ast } from '@/util/ast'
Expand Down Expand Up @@ -44,7 +44,7 @@ test.each([
"'\\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(tsvToEnsoTable(tableData)).toEqual(expectedEnsoExpression)
expect(tsvTableToEnsoExpression(tableData)).toEqual(expectedEnsoExpression)
})

class MockClipboardItem {
Expand Down Expand Up @@ -83,12 +83,8 @@ const testNodes = testNodeInputs.map(({ code, visualization, colorOverride }) =>
test.each([...testNodes.map((node) => [node]), testNodes])(
'Copy and paste nodes',
async (...sourceNodes) => {
const clipboardItems = nodesToClipboardData(
sourceNodes,
(data) => new MockClipboardItem(data as any) as any,
(parts, type) => new Blob(parts, { type }) as any,
)
const pastedNodes = await nodesFromClipboardContent(clipboardItems)
const clipboardItem = clipboardItemFromTypes(nodesToClipboardData(sourceNodes))
const pastedNodes = await nodesFromClipboardContent([clipboardItem])
sourceNodes.forEach((sourceNode, i) => {
expect(pastedNodes[i]?.documentation).toBe(sourceNode.documentation)
expect(pastedNodes[i]?.expression).toBe(sourceNode.innerExpr.code())
Expand Down
93 changes: 52 additions & 41 deletions app/gui2/src/components/GraphEditor/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,6 @@ interface CopiedNode {
metadata?: NodeMetadataFields
}

function nodeStructuredData(node: Node): CopiedNode {
return {
expression: node.innerExpr.code(),
documentation: node.documentation,
metadata: node.rootExpr.serializeMetadata(),
...(node.pattern ? { binding: node.pattern.code() } : {}),
}
}

function nodeDataFromExpressionText(expression: string): CopiedNode {
return { expression }
}

/** @internal Exported for testing. */
export async function nodesFromClipboardContent(
clipboardItems: ClipboardItems,
Expand All @@ -50,34 +37,20 @@ export async function nodesFromClipboardContent(

const ensoDecoder: ClipboardDecoder<CopiedNode[]> = {
mimeType: ENSO_MIME_TYPE,
decode: async (blob) => JSON.parse(await blob.text()).nodes,
decode: async (blob) => (JSON.parse(await blob.text()) as ClipboardData).nodes,
}
const plainTextDecoder: ClipboardDecoder<CopiedNode[]> = {
mimeType: 'text/plain',
decode: async (blob) => [nodeDataFromExpressionText(await blob.text())],
decode: async (blob) => [{ expression: await blob.text() }],
}

type clipboardItemFactory = (itemData: Record<string, Blob>) => ClipboardItem
type blobFactory = (parts: string[], type: string) => Blob

/** @internal Exported for testing. */
export function nodesToClipboardData(
nodes: Node[],
makeClipboardItem: clipboardItemFactory = (data) => new ClipboardItem(data),
makeBlob: blobFactory = (parts, type) => new Blob(parts, { type }),
): ClipboardItem[] {
const clipboardData: ClipboardData = { nodes: nodes.map(nodeStructuredData) }
const jsonItem = makeBlob([JSON.stringify(clipboardData)], ENSO_MIME_TYPE)
const textItem = makeBlob([nodes.map((node) => node.outerExpr.code()).join('\n')], 'text/plain')
return [
makeClipboardItem({
[jsonItem.type]: jsonItem,
[textItem.type]: textItem,
}),
]
interface ExtendedClipboard extends Clipboard {
// Recent addition to the spec: https://github.com/w3c/clipboard-apis/pull/197
// Currently supported by Chromium: https://developer.chrome.com/docs/web-platform/unsanitized-html-async-clipboard
read(options?: { unsanitized?: ['text/html'] }): Promise<ClipboardItems>
}

function getClipboard() {
function getClipboard(): ExtendedClipboard {
return (window.navigator as any).mockClipboard ?? window.navigator.clipboard
}

Expand All @@ -87,7 +60,7 @@ export function useGraphEditorClipboard(
createNodes: (nodesOptions: Iterable<NodeCreationOptions>) => void,
) {
/** Copy the content of the selected node to the clipboard. */
function copySelectionToClipboard() {
async function copySelectionToClipboard() {
const nodes = new Array<Node>()
const ids = graphStore.pickInCodeOrder(toValue(selected))
for (const id of ids) {
Expand All @@ -96,9 +69,7 @@ export function useGraphEditorClipboard(
nodes.push(node)
}
if (!nodes.length) return
getClipboard()
.write(nodesToClipboardData(nodes))
.catch((error: any) => console.error(`Failed to write to clipboard: ${error}`))
return writeClipboard(nodesToClipboardData(nodes))
}

/** Read the clipboard and if it contains valid data, create nodes from the content. */
Expand Down Expand Up @@ -137,7 +108,9 @@ export function useGraphEditorClipboard(
}
}

// ==========================
// === Clipboard decoding ===
// ==========================

interface ClipboardDecoder<T> {
mimeType: string
Expand Down Expand Up @@ -169,15 +142,14 @@ 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 [nodeDataFromExpressionText(tsvToEnsoTable(textData))]
return [{ expression: tsvTableToEnsoExpression(textData) }]
}
},
}

const toTable = computed(() => Pattern.parse('__.to Table'))

/** @internal Exported for testing. */
export function tsvToEnsoTable(tsvData: string) {
export function tsvTableToEnsoExpression(tsvData: string) {
const textLiteral = Ast.TextLiteral.new(tsvData)
return toTable.value.instantiate(textLiteral.module, [textLiteral]).code()
}
Expand All @@ -191,3 +163,42 @@ export function isSpreadsheetTsv(htmlContent: string) {
// acceptable.
return /<table[ >]/i.test(htmlContent)
}

// =========================
// === Clipboard writing ===
// =========================

export type MimeType = 'text/plain' | 'text/html' | typeof ENSO_MIME_TYPE
export type MimeData = Partial<Record<MimeType, string>>

export function writeClipboard(data: MimeData) {
const dataBlobs = Object.fromEntries(
Object.entries(data).map(([type, typeData]) => [type, new Blob([typeData], { type })]),
)
return getClipboard()
.write([new ClipboardItem(dataBlobs)])
.catch((error: any) => console.error(`Failed to write to clipboard: ${error}`))
}

// === Serializing nodes ===

function nodeStructuredData(node: Node): CopiedNode {
return {
expression: node.innerExpr.code(),
documentation: node.documentation,
metadata: node.rootExpr.serializeMetadata(),
...(node.pattern ? { binding: node.pattern.code() } : {}),
}
}

export function clipboardNodeData(nodes: CopiedNode[]): MimeData {
const clipboardData: ClipboardData = { nodes }
return { [ENSO_MIME_TYPE]: JSON.stringify(clipboardData) }
}

export function nodesToClipboardData(nodes: Node[]): MimeData {
return {
...clipboardNodeData(nodes.map(nodeStructuredData)),
'text/plain': nodes.map((node) => node.outerExpr.code()).join('\n'),
}
}
32 changes: 32 additions & 0 deletions app/gui2/src/components/visualizations/TableVisualization.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<script lang="ts">
import icons from '@/assets/icons.svg'
import {
clipboardNodeData,
tsvTableToEnsoExpression,
writeClipboard,
} from '@/components/GraphEditor/clipboard'
import { Ast } from '@/util/ast'
import { Pattern } from '@/util/ast/match'
import { useAutoBlur } from '@/util/autoBlur'
Expand Down Expand Up @@ -131,6 +136,8 @@ const agGridOptions: Ref<GridOptions & Required<Pick<GridOptions, 'defaultColDef
onFirstDataRendered: updateColumnWidths,
onRowDataUpdated: updateColumnWidths,
onColumnResized: lockColumnSize,
copyHeadersToClipboard: true,
sendToClipboard: ({ data }: { data: string }) => sendToClipboard(data),
suppressFieldDotNotation: true,
enableRangeSelection: true,
popupParent: document.body,
Expand Down Expand Up @@ -465,6 +472,31 @@ function lockColumnSize(e: ColumnResizedEvent) {
}
}
/** Copy the provided TSV-formatted table data to the clipboard.
*
* The data will be copied as `text/plain` TSV data for spreadsheet applications, and an Enso-specific MIME section for
* pasting as a new table node.
*
* By default, AG Grid writes only `text/plain` TSV data to the clipboard. This is sufficient to paste into spreadsheet
* applications, which are liberal in what they try to interpret as tabular data; however, when pasting into Enso, the
* application needs to be able to distinguish tabular clipboard contents to choose the correct paste action.
*
* Our heuristic to identify clipboard data from applications like Excel and Google Sheets is to check for a <table> tag
* in the clipboard `text/html` data. If we were to add a `text/html` section to the data so that it could be recognized
* like other spreadsheets, when pasting into other applications some applications might use the `text/html` data in
* preference to the `text/plain` content--so we would need to construct an HTML table that fully represents the
* content.
*
* To avoid that complexity, we bypass our table-data detection by including application-specific data in the clipboard
* content. This data contains a ready-to-paste node that constructs an Enso table from the provided TSV.
*/
function sendToClipboard(tsvData: string) {
return writeClipboard({
...clipboardNodeData([{ expression: tsvTableToEnsoExpression(tsvData) }]),
'text/plain': tsvData,
})
}
// ===============
// === Updates ===
// ===============
Expand Down

0 comments on commit e7bc071

Please sign in to comment.