Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copied table-viz range pastes as Table component #10352

Merged
merged 4 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading