mirror of
https://github.com/enso-org/enso.git
synced 2024-09-11 13:15:52 +03:00
Copy/paste improvements (#9734)
Copying nodes: - Multiple nodes supported. - Node comments and user-specified colors included. - Google Sheets data can be pasted to produce a `Table` node, handled the same way as Excel data. # Important Notes - Fix E2E tests on OS X. - Add E2E and unit tests for clipboard. - Use the lexer to test text escaping; fix text escaping issues and inconsistencies.
This commit is contained in:
parent
7c571bd460
commit
6426478c97
@ -1,13 +1,13 @@
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import os from 'os'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { mockCollapsedFunctionInfo } from './expressionUpdates'
|
||||
import { CONTROL_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
|
||||
const MAIN_FILE_NODES = 11
|
||||
|
||||
const COLLAPSE_SHORTCUT = os.platform() === 'darwin' ? 'Meta+G' : 'Control+G'
|
||||
const COLLAPSE_SHORTCUT = `${CONTROL_KEY}+G`
|
||||
|
||||
test('Entering nodes', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import os from 'os'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { CONTROL_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
|
||||
const CONTROL_KEY = os.platform() === 'darwin' ? 'Meta' : 'Control'
|
||||
const ACCEPT_SUGGESTION_SHORTCUT = `${CONTROL_KEY}+Enter`
|
||||
|
||||
async function deselectAllNodes(page: Page) {
|
||||
|
4
app/gui2/e2e/keyboard.ts
Normal file
4
app/gui2/e2e/keyboard.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import os from 'os'
|
||||
|
||||
export const CONTROL_KEY = os.platform() === 'darwin' ? 'Meta' : 'Control'
|
||||
export const DELETE_KEY = os.platform() === 'darwin' ? 'Backspace' : 'Delete'
|
70
app/gui2/e2e/nodeClipboard.spec.ts
Normal file
70
app/gui2/e2e/nodeClipboard.spec.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import test from 'playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { CONTROL_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
class MockClipboard {
|
||||
private contents: ClipboardItem[] = []
|
||||
async read(): Promise<ClipboardItem[]> {
|
||||
return [...this.contents]
|
||||
}
|
||||
async write(contents: ClipboardItem[]) {
|
||||
this.contents = [...contents]
|
||||
}
|
||||
}
|
||||
Object.assign(window.navigator, {
|
||||
mockClipboard: new MockClipboard(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Copy node with comment', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
|
||||
// Check state before operation.
|
||||
const originalNodes = await locate.graphNode(page).count()
|
||||
await expect(page.locator('.GraphNodeComment')).toExist()
|
||||
const originalNodeComments = await page.locator('.GraphNodeComment').count()
|
||||
|
||||
// Select a node.
|
||||
const nodeToCopy = locate.graphNodeByBinding(page, 'final')
|
||||
await nodeToCopy.click()
|
||||
await expect(nodeToCopy).toBeSelected()
|
||||
// Copy and paste it.
|
||||
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
||||
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||
await expect(nodeToCopy).toBeSelected()
|
||||
|
||||
// Node and comment have been copied.
|
||||
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1)
|
||||
await expect(page.locator('.GraphNodeComment')).toHaveCount(originalNodeComments + 1)
|
||||
})
|
||||
|
||||
test('Copy multiple nodes', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
|
||||
// Check state before operation.
|
||||
const originalNodes = await locate.graphNode(page).count()
|
||||
await expect(page.locator('.GraphNodeComment')).toExist()
|
||||
const originalNodeComments = await page.locator('.GraphNodeComment').count()
|
||||
|
||||
// Select some nodes.
|
||||
const node1 = locate.graphNodeByBinding(page, 'final')
|
||||
await node1.click()
|
||||
const node2 = locate.graphNodeByBinding(page, 'data')
|
||||
await node2.click({ modifiers: ['Shift'] })
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
// Copy and paste.
|
||||
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
||||
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
|
||||
// Nodes and comment have been copied.
|
||||
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 2)
|
||||
await expect(page.locator('.GraphNodeComment')).toHaveCount(originalNodeComments + 1)
|
||||
})
|
@ -1,6 +1,7 @@
|
||||
import test from 'playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { CONTROL_KEY, DELETE_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
|
||||
test('Adding new node', async ({ page }) => {
|
||||
@ -10,18 +11,18 @@ test('Adding new node', async ({ page }) => {
|
||||
await locate.addNewNodeButton(page).click()
|
||||
await expect(locate.componentBrowserInput(page)).toBeVisible()
|
||||
await page.keyboard.insertText('foo')
|
||||
await page.keyboard.press('Control+Enter')
|
||||
await page.keyboard.press(`${CONTROL_KEY}+Enter`)
|
||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount + 1)
|
||||
await expect(locate.graphNode(page).last().locator('.WidgetToken')).toHaveText(['foo'])
|
||||
const newNodeBBox = await locate.graphNode(page).last().boundingBox()
|
||||
|
||||
await page.keyboard.press('Control+Z')
|
||||
await page.keyboard.press(`${CONTROL_KEY}+Z`)
|
||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount)
|
||||
await expect(
|
||||
locate.graphNode(page).locator('.WidgetToken').filter({ hasText: 'foo' }),
|
||||
).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press('Control+Shift+Z')
|
||||
await page.keyboard.press(`${CONTROL_KEY}+Shift+Z`)
|
||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount + 1)
|
||||
await expect(locate.graphNode(page).last().locator('.WidgetToken')).toHaveText(['foo'])
|
||||
const restoredBox = await locate.graphNode(page).last().boundingBox()
|
||||
@ -35,17 +36,17 @@ test('Removing node', async ({ page }) => {
|
||||
const deletedNode = locate.graphNodeByBinding(page, 'final')
|
||||
const deletedNodeBBox = await deletedNode.boundingBox()
|
||||
await deletedNode.click()
|
||||
await page.keyboard.press('Delete')
|
||||
await page.keyboard.press(DELETE_KEY)
|
||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
|
||||
|
||||
await page.keyboard.press('Control+Z')
|
||||
await page.keyboard.press(`${CONTROL_KEY}+Z`)
|
||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount)
|
||||
await expect(deletedNode.locator('.WidgetToken')).toHaveText(['Main', '.', 'func1', 'prod'])
|
||||
await expect(deletedNode.locator('.GraphNodeComment')).toHaveText('This node can be entered')
|
||||
const restoredBBox = await deletedNode.boundingBox()
|
||||
await expect(restoredBBox).toEqual(deletedNodeBBox)
|
||||
|
||||
await page.keyboard.press('Control+Shift+Z')
|
||||
await page.keyboard.press(`${CONTROL_KEY}+Shift+Z`)
|
||||
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
|
||||
await expect(deletedNode).not.toBeVisible()
|
||||
})
|
||||
|
@ -4,7 +4,8 @@
|
||||
* `lib/rust/parser/src/lexer.rs`, search for `fn text_escape`.
|
||||
*/
|
||||
|
||||
import { assertUnreachable } from '../util/assert'
|
||||
import { assertDefined } from '../util/assert'
|
||||
import { TextLiteral } from './tree'
|
||||
|
||||
const escapeSequences = [
|
||||
['0', '\0'],
|
||||
@ -17,7 +18,6 @@ const escapeSequences = [
|
||||
['v', '\x0B'],
|
||||
['e', '\x1B'],
|
||||
['\\', '\\'],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
['`', '`'],
|
||||
] as const
|
||||
@ -28,52 +28,41 @@ function escapeAsCharCodes(str: string): string {
|
||||
return out
|
||||
}
|
||||
|
||||
const fixedEscapes = escapeSequences.map(([_, raw]) => escapeAsCharCodes(raw))
|
||||
const escapeRegex = new RegExp(
|
||||
`${escapeSequences.map(([_, raw]) => escapeAsCharCodes(raw)).join('|')}`,
|
||||
'gu',
|
||||
)
|
||||
|
||||
const unescapeRegex = new RegExp(
|
||||
'\\\\(?:' +
|
||||
`${escapeSequences.map(([escape]) => escapeAsCharCodes(escape)).join('|')}` +
|
||||
'|x[0-9a-fA-F]{0,2}' +
|
||||
'|u\\{[0-9a-fA-F]{0,4}\\}?' + // Lexer allows trailing } to be missing.
|
||||
'|u[0-9a-fA-F]{0,4}' +
|
||||
'|U[0-9a-fA-F]{0,8}' +
|
||||
')',
|
||||
[
|
||||
...fixedEscapes,
|
||||
// Unpaired-surrogate codepoints are not technically valid in Unicode, but they are allowed in Javascript strings.
|
||||
// Enso source files must be strictly UTF-8 conformant.
|
||||
'\\p{Surrogate}',
|
||||
].join('|'),
|
||||
'gu',
|
||||
)
|
||||
|
||||
const escapeMapping = Object.fromEntries(
|
||||
escapeSequences.map(([escape, raw]) => [raw, `\\${escape}`]),
|
||||
)
|
||||
const unescapeMapping = Object.fromEntries(
|
||||
escapeSequences.map(([escape, raw]) => [`\\${escape}`, raw]),
|
||||
)
|
||||
|
||||
function escapeChar(char: string) {
|
||||
const fixedEscape = escapeMapping[char]
|
||||
if (fixedEscape != null) return fixedEscape
|
||||
return escapeAsCharCodes(char)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
|
||||
* Note: Escape sequences are NOT interpreted in raw (`""`) string literals.
|
||||
* */
|
||||
export function escapeTextLiteral(rawString: string) {
|
||||
return rawString.replace(escapeRegex, (match) => escapeMapping[match] ?? assertUnreachable())
|
||||
return rawString.replace(escapeRegex, escapeChar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret all escaped characters from an interpolated (`''`) Enso string.
|
||||
* Interpret all escaped characters from an interpolated (`''`) Enso string, provided without open/close delimiters.
|
||||
* Note: Escape sequences are NOT interpreted in raw (`""`) string literals.
|
||||
*/
|
||||
export function unescapeTextLiteral(escapedString: string) {
|
||||
return escapedString.replace(unescapeRegex, (match) => {
|
||||
let cut = 2
|
||||
switch (match[1]) {
|
||||
case 'u':
|
||||
if (match[2] === '{') cut = 3 // fallthrough
|
||||
case 'U':
|
||||
case 'x':
|
||||
return String.fromCharCode(parseInt(match.substring(cut), 16))
|
||||
default:
|
||||
return unescapeMapping[match] ?? assertUnreachable()
|
||||
}
|
||||
})
|
||||
const ast = TextLiteral.tryParse("'" + escapedString + "'")
|
||||
assertDefined(ast)
|
||||
return ast.rawTextContent
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import { assert, assertDefined, assertEqual, bail } from '../util/assert'
|
||||
import type { Result } from '../util/data/result'
|
||||
import { Err, Ok } from '../util/data/result'
|
||||
import type { SourceRangeEdit } from '../util/data/text'
|
||||
import { allKeys } from '../util/types'
|
||||
import type { ExternalId, VisualizationMetadata } from '../yjsModel'
|
||||
import { visMetadataEquals } from '../yjsModel'
|
||||
import * as RawAst from './generated/ast'
|
||||
@ -54,6 +55,11 @@ export interface NodeMetadataFields {
|
||||
visualization?: VisualizationMetadata | undefined
|
||||
colorOverride?: string | undefined
|
||||
}
|
||||
const nodeMetadataKeys = allKeys<NodeMetadataFields>({
|
||||
position: null,
|
||||
visualization: null,
|
||||
colorOverride: null,
|
||||
})
|
||||
export type NodeMetadata = FixedMapView<NodeMetadataFields>
|
||||
export type MutableNodeMetadata = FixedMap<NodeMetadataFields>
|
||||
export function asNodeMetadata(map: Map<string, unknown>): NodeMetadata {
|
||||
@ -67,9 +73,6 @@ interface RawAstFields {
|
||||
metadata: FixedMap<MetadataFields>
|
||||
}
|
||||
export interface AstFields extends RawAstFields, LegalFieldContent {}
|
||||
function allKeys<T>(keys: Record<keyof T, any>): (keyof T)[] {
|
||||
return Object.keys(keys) as any
|
||||
}
|
||||
const astFieldKeys = allKeys<RawAstFields>({
|
||||
id: null,
|
||||
type: null,
|
||||
@ -96,6 +99,11 @@ export abstract class Ast {
|
||||
return metadata as FixedMapView<NodeMetadataFields>
|
||||
}
|
||||
|
||||
/** Returns a JSON-compatible object containing all metadata properties. */
|
||||
serializeMetadata(): MetadataFields & NodeMetadataFields {
|
||||
return this.fields.get('metadata').toJSON() as any
|
||||
}
|
||||
|
||||
typeName(): string {
|
||||
return this.fields.get('type')
|
||||
}
|
||||
@ -200,8 +208,14 @@ export abstract class MutableAst extends Ast {
|
||||
|
||||
setNodeMetadata(nodeMeta: NodeMetadataFields) {
|
||||
const metadata = this.fields.get('metadata') as unknown as Map<string, unknown>
|
||||
for (const [key, value] of Object.entries(nodeMeta))
|
||||
if (value !== undefined) metadata.set(key, value)
|
||||
for (const [key, value] of Object.entries(nodeMeta)) {
|
||||
if (!nodeMetadataKeys.has(key)) continue
|
||||
if (value === undefined) {
|
||||
metadata.delete(key)
|
||||
} else {
|
||||
metadata.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Modify the parent of this node to refer to a new object instead. Return the object, which now has no parent. */
|
||||
@ -372,7 +386,7 @@ interface FieldObject<T extends TreeRefs> {
|
||||
function* fieldDataEntries<Fields>(map: FixedMapView<Fields>) {
|
||||
for (const entry of map.entries()) {
|
||||
// All fields that are not from `AstFields` are `FieldData`.
|
||||
if (!astFieldKeys.includes(entry[0] as any)) yield entry as [string, DeepReadonly<FieldData>]
|
||||
if (!astFieldKeys.has(entry[0])) yield entry as [string, DeepReadonly<FieldData>]
|
||||
}
|
||||
}
|
||||
|
||||
@ -2413,6 +2427,7 @@ export interface FixedMapView<Fields> {
|
||||
entries(): IterableIterator<readonly [string, unknown]>
|
||||
clone(): FixedMap<Fields>
|
||||
has(key: string): boolean
|
||||
toJSON(): object
|
||||
}
|
||||
|
||||
export interface FixedMap<Fields> extends FixedMapView<Fields> {
|
||||
|
5
app/gui2/shared/util/types.ts
Normal file
5
app/gui2/shared/util/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/** Returns an all the keys of a type. The argument provided is required to be an object containing all the keys of the
|
||||
* type (including optional fields), but the associated values are ignored and may be of any type. */
|
||||
export function allKeys<T>(keys: { [P in keyof T]-?: any }): ReadonlySet<string> {
|
||||
return Object.freeze(new Set(Object.keys(keys)))
|
||||
}
|
@ -3,6 +3,7 @@ import { codeEditorBindings, graphBindings, interactionBindings } from '@/bindin
|
||||
import CodeEditor from '@/components/CodeEditor.vue'
|
||||
import ColorPicker from '@/components/ColorPicker.vue'
|
||||
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||
import { type Usage } from '@/components/ComponentBrowser/input'
|
||||
import {
|
||||
DEFAULT_NODE_SIZE,
|
||||
mouseDictatedPlacement,
|
||||
@ -10,6 +11,7 @@ import {
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
|
||||
import GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
|
||||
import { useGraphEditorClipboard } from '@/components/GraphEditor/clipboard'
|
||||
import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import { useGraphEditorToasts } from '@/components/GraphEditor/toasts'
|
||||
@ -42,8 +44,6 @@ import { Vec2 } from '@/util/data/vec2'
|
||||
import { encoding, set } from 'lib0'
|
||||
import { encodeMethodPointer } from 'shared/languageServerTypes'
|
||||
import { computed, onMounted, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { type Usage } from './ComponentBrowser/input'
|
||||
import { useGraphEditorClipboard } from './GraphEditor/clipboard'
|
||||
|
||||
const keyboard = provideKeyboard()
|
||||
const graphStore = useGraphStore()
|
||||
@ -111,7 +111,7 @@ watch(
|
||||
|
||||
// === Clipboard Copy/Paste ===
|
||||
|
||||
const { copyNodeContent, readNodeFromClipboard } = useGraphEditorClipboard(
|
||||
const { copySelectionToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(
|
||||
nodeSelection,
|
||||
graphNavigator,
|
||||
)
|
||||
@ -198,11 +198,11 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
},
|
||||
copyNode() {
|
||||
if (keyboardBusy()) return false
|
||||
copyNodeContent()
|
||||
copySelectionToClipboard()
|
||||
},
|
||||
pasteNode() {
|
||||
if (keyboardBusy()) return false
|
||||
readNodeFromClipboard()
|
||||
createNodesFromClipboard()
|
||||
},
|
||||
collapse() {
|
||||
if (keyboardBusy()) return false
|
||||
|
@ -0,0 +1,97 @@
|
||||
import {
|
||||
excelTableToEnso,
|
||||
nodesFromClipboardContent,
|
||||
nodesToClipboardData,
|
||||
} from '@/components/GraphEditor/clipboard'
|
||||
import { type Node } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { initializePrefixes, nodeFromAst } from '@/util/ast/node'
|
||||
import { Blob } from 'node:buffer'
|
||||
import { initializeFFI } from 'shared/ast/ffi'
|
||||
import { assertDefined } from 'shared/util/assert'
|
||||
import { type VisualizationMetadata } from 'shared/yjsModel'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
await initializeFFI()
|
||||
initializePrefixes()
|
||||
|
||||
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(excelTableToEnso(tableData)).toEqual(expectedEnsoExpression)
|
||||
})
|
||||
|
||||
class MockClipboardItem {
|
||||
readonly types: ReadonlyArray<string>
|
||||
|
||||
constructor(private data: Record<string, Blob>) {
|
||||
this.types = Object.keys(data)
|
||||
}
|
||||
|
||||
getType(type: string): Blob {
|
||||
const blob = this.data[type]
|
||||
assertDefined(blob)
|
||||
return blob
|
||||
}
|
||||
}
|
||||
|
||||
const testNodeInputs: {
|
||||
code: string
|
||||
visualization?: VisualizationMetadata
|
||||
colorOverride?: string
|
||||
}[] = [
|
||||
{ code: '2 + 2' },
|
||||
{ code: 'foo = bar' },
|
||||
{ code: '## Documentation\n2 + 2', colorOverride: 'mauve' },
|
||||
{ code: '## Documentation\nfoo = 2 + 2' },
|
||||
]
|
||||
const testNodes = testNodeInputs.map(({ code, visualization, colorOverride }) => {
|
||||
const root = Ast.Ast.parse(code)
|
||||
root.setNodeMetadata({ visualization, colorOverride })
|
||||
const node = nodeFromAst(root)
|
||||
assertDefined(node)
|
||||
// `nodesToClipboardData` only needs the `NodeDataFromAst` fields of `Node`, because it reads the metadata directly
|
||||
// from the AST.
|
||||
return node as Node
|
||||
})
|
||||
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)
|
||||
sourceNodes.forEach((sourceNode, i) => {
|
||||
expect(pastedNodes[i]?.documentation).toBe(sourceNode.documentation)
|
||||
expect(pastedNodes[i]?.expression).toBe(sourceNode.innerExpr.code())
|
||||
expect(pastedNodes[i]?.metadata?.colorOverride).toBe(sourceNode.colorOverride)
|
||||
expect(pastedNodes[i]?.metadata?.visualization).toBe(sourceNode.vis)
|
||||
})
|
||||
},
|
||||
)
|
@ -1,10 +1,17 @@
|
||||
import type { NavigatorComposable } from '@/composables/navigator'
|
||||
import type { GraphSelection } from '@/providers/graphSelection'
|
||||
import type { Node } from '@/stores/graph'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { NodeMetadataFields } from 'shared/ast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const ENSO_MIME_TYPE = 'web application/enso'
|
||||
// MIME type in *vendor tree*; see https://www.rfc-editor.org/rfc/rfc6838#section-3.2
|
||||
// The `web ` prefix is required by Chromium:
|
||||
// https://developer.chrome.com/blog/web-custom-formats-for-the-async-clipboard-api/.
|
||||
const ENSO_MIME_TYPE = 'web application/vnd.enso.enso'
|
||||
|
||||
/** The data that is copied to the clipboard. */
|
||||
interface ClipboardData {
|
||||
@ -14,7 +21,102 @@ interface ClipboardData {
|
||||
/** Node data that is copied to the clipboard. Used for serializing and deserializing the node information. */
|
||||
interface CopiedNode {
|
||||
expression: string
|
||||
metadata: NodeMetadataFields | undefined
|
||||
documentation?: string | undefined
|
||||
metadata?: NodeMetadataFields
|
||||
}
|
||||
|
||||
function nodeStructuredData(node: Node): CopiedNode {
|
||||
return {
|
||||
expression: node.innerExpr.code(),
|
||||
documentation: node.documentation,
|
||||
metadata: node.rootExpr.serializeMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
function nodeDataFromExpressionText(expression: string): CopiedNode {
|
||||
return { expression }
|
||||
}
|
||||
|
||||
const toTable = computed(() => Pattern.parse('__.to Table'))
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function excelTableToEnso(excelData: string) {
|
||||
const textLiteral = Ast.TextLiteral.new(excelData)
|
||||
return toTable.value.instantiate(textLiteral.module, [textLiteral]).code()
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export async function nodesFromClipboardContent(
|
||||
clipboardItems: ClipboardItems,
|
||||
): Promise<CopiedNode[]> {
|
||||
let fallbackItem: ClipboardItem | undefined
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
for (const type of clipboardItem.types) {
|
||||
if (type === ENSO_MIME_TYPE) {
|
||||
const blob = await clipboardItem.getType(type)
|
||||
return JSON.parse(await blob.text()).nodes
|
||||
}
|
||||
|
||||
if (type === 'text/html') {
|
||||
const blob = await clipboardItem.getType(type)
|
||||
const htmlContent = await blob.text()
|
||||
const excelNode = await nodeDataFromExcelClipboard(htmlContent, clipboardItem)
|
||||
if (excelNode) {
|
||||
return [excelNode]
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'text/plain') {
|
||||
fallbackItem = clipboardItem
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fallbackItem) {
|
||||
const fallbackData = await fallbackItem.getType('text/plain')
|
||||
return [nodeDataFromExpressionText(await fallbackData.text())]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Excel data starts with a `table` tag; Google Sheets starts with its own marker.
|
||||
const spreadsheetHtmlRegex = /^(?:<table |<google-sheets-html-origin>).*<\/table>$/
|
||||
|
||||
async function nodeDataFromExcelClipboard(
|
||||
htmlContent: string,
|
||||
clipboardItem: ClipboardItem,
|
||||
): Promise<CopiedNode | undefined> {
|
||||
// Check if the contents look like HTML tables produced by spreadsheet software known to provide a plain-text
|
||||
// version of the table with tab separators, as Excel does.
|
||||
if (clipboardItem.types.includes('text/plain') && spreadsheetHtmlRegex.test(htmlContent)) {
|
||||
const textData = await clipboardItem.getType('text/plain')
|
||||
const expression = excelTableToEnso(await textData.text())
|
||||
return nodeDataFromExpressionText(expression)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function getClipboard() {
|
||||
return (window.navigator as any).mockClipboard ?? window.navigator.clipboard
|
||||
}
|
||||
|
||||
export function useGraphEditorClipboard(
|
||||
@ -24,97 +126,41 @@ export function useGraphEditorClipboard(
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
/** Copy the content of the selected node to the clipboard. */
|
||||
function copyNodeContent() {
|
||||
const id = nodeSelection.selected.values().next().value
|
||||
const node = graphStore.db.nodeIdToNode.get(id)
|
||||
if (!node) return
|
||||
const content = node.innerExpr.code()
|
||||
const nodeMetadata = node.rootExpr.nodeMetadata
|
||||
const metadata = {
|
||||
position: nodeMetadata.get('position'),
|
||||
visualization: nodeMetadata.get('visualization'),
|
||||
function copySelectionToClipboard() {
|
||||
const nodes = new Array<Node>()
|
||||
for (const id of nodeSelection.selected) {
|
||||
const node = graphStore.db.nodeIdToNode.get(id)
|
||||
if (!node) continue
|
||||
nodes.push(node)
|
||||
}
|
||||
const copiedNode: CopiedNode = { expression: content, metadata }
|
||||
const clipboardData: ClipboardData = { nodes: [copiedNode] }
|
||||
const jsonItem = new Blob([JSON.stringify(clipboardData)], { type: ENSO_MIME_TYPE })
|
||||
const textItem = new Blob([content], { type: 'text/plain' })
|
||||
const clipboardItem = new ClipboardItem({
|
||||
[jsonItem.type]: jsonItem,
|
||||
[textItem.type]: textItem,
|
||||
})
|
||||
navigator.clipboard.write([clipboardItem])
|
||||
if (!nodes.length) return
|
||||
getClipboard()
|
||||
.write(nodesToClipboardData(nodes))
|
||||
.catch((error: any) => console.error(`Failed to write to clipboard: ${error}`))
|
||||
}
|
||||
|
||||
async function retrieveDataFromClipboard(): Promise<ClipboardData | undefined> {
|
||||
const clipboardItems = await navigator.clipboard.read()
|
||||
let fallback = undefined
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
for (const type of clipboardItem.types) {
|
||||
if (type === ENSO_MIME_TYPE) {
|
||||
const blob = await clipboardItem.getType(type)
|
||||
return JSON.parse(await blob.text())
|
||||
}
|
||||
|
||||
if (type === 'text/html') {
|
||||
const blob = await clipboardItem.getType(type)
|
||||
const htmlContent = await blob.text()
|
||||
const excelPayload = await readNodeFromExcelClipboard(htmlContent, clipboardItem)
|
||||
if (excelPayload) {
|
||||
return excelPayload
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'text/plain') {
|
||||
const blob = await clipboardItem.getType(type)
|
||||
const fallbackExpression = await blob.text()
|
||||
const fallbackNode = { expression: fallbackExpression, metadata: undefined } as CopiedNode
|
||||
fallback = { nodes: [fallbackNode] } as ClipboardData
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/// Read the clipboard and if it contains valid data, create a node from the content.
|
||||
async function readNodeFromClipboard() {
|
||||
const clipboardData = await retrieveDataFromClipboard()
|
||||
const copiedNode = clipboardData?.nodes[0]
|
||||
if (!copiedNode) {
|
||||
/** Read the clipboard and if it contains valid data, create nodes from the content. */
|
||||
async function createNodesFromClipboard() {
|
||||
const clipboardItems = await getClipboard().read()
|
||||
const clipboardData = await nodesFromClipboardContent(clipboardItems)
|
||||
if (!clipboardData.length) {
|
||||
console.warn('No valid node in clipboard.')
|
||||
return
|
||||
}
|
||||
if (copiedNode.expression == null) {
|
||||
console.warn('No valid expression in clipboard.')
|
||||
for (const copiedNode of clipboardData) {
|
||||
const { expression, documentation, metadata } = copiedNode
|
||||
graphStore.createNode(
|
||||
(clipboardData.length === 1 ? graphNavigator.sceneMousePos : null) ?? Vec2.Zero,
|
||||
expression,
|
||||
metadata,
|
||||
undefined,
|
||||
documentation,
|
||||
)
|
||||
}
|
||||
graphStore.createNode(
|
||||
graphNavigator.sceneMousePos ?? Vec2.Zero,
|
||||
copiedNode.expression,
|
||||
copiedNode.metadata,
|
||||
)
|
||||
}
|
||||
|
||||
async function readNodeFromExcelClipboard(
|
||||
htmlContent: string,
|
||||
clipboardItem: ClipboardItem,
|
||||
): Promise<ClipboardData | undefined> {
|
||||
// Check we have a valid HTML table
|
||||
// If it is Excel, we should have a plain-text version of the table with tab separators.
|
||||
if (
|
||||
clipboardItem.types.includes('text/plain') &&
|
||||
htmlContent.startsWith('<table ') &&
|
||||
htmlContent.endsWith('</table>')
|
||||
) {
|
||||
const textData = await clipboardItem.getType('text/plain')
|
||||
const text = await textData.text()
|
||||
const payload = JSON.stringify(text).replaceAll(/^"|"$/g, '').replaceAll("'", "\\'")
|
||||
const expression = `'${payload}'.to Table`
|
||||
return { nodes: [{ expression: expression, metadata: undefined }] } as ClipboardData
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
copyNodeContent,
|
||||
readNodeFromClipboard,
|
||||
copySelectionToClipboard,
|
||||
createNodesFromClipboard,
|
||||
}
|
||||
}
|
||||
|
@ -253,8 +253,9 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
nodeOptions: {
|
||||
position: Vec2
|
||||
expression: string
|
||||
metadata?: NodeMetadataFields
|
||||
metadata?: NodeMetadataFields | undefined
|
||||
withImports?: RequiredImport[] | undefined
|
||||
documentation?: string | undefined
|
||||
}[],
|
||||
): NodeId[] {
|
||||
const method = syncModule.value ? methodAstInModule(syncModule.value) : undefined
|
||||
@ -268,14 +269,15 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
for (const options of nodeOptions) {
|
||||
const ident = generateUniqueIdent()
|
||||
const metadata = { ...options.metadata, position: options.position.xy() }
|
||||
const { assignment, id } = newAssignmentNode(
|
||||
const { rootExpression, id } = newAssignmentNode(
|
||||
edit,
|
||||
ident,
|
||||
options.expression,
|
||||
metadata,
|
||||
options.withImports ?? [],
|
||||
options.documentation,
|
||||
)
|
||||
bodyBlock.push(assignment)
|
||||
bodyBlock.push(rootExpression)
|
||||
created.push(id)
|
||||
nodeRects.set(id, new Rect(options.position, Vec2.Zero))
|
||||
}
|
||||
@ -289,6 +291,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
expression: string,
|
||||
metadata: NodeMetadataFields,
|
||||
withImports: RequiredImport[],
|
||||
documentation: string | undefined,
|
||||
) {
|
||||
const conflicts = addMissingImports(edit, withImports) ?? []
|
||||
const rhs = Ast.parse(expression, edit)
|
||||
@ -300,7 +303,9 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
// substituteQualifiedName(edit, assignment, conflict.pattern, conflict.fullyQualified)
|
||||
}
|
||||
const id = asNodeId(rhs.id)
|
||||
return { assignment, id }
|
||||
const rootExpression =
|
||||
documentation != null ? Ast.Documented.new(documentation, assignment) : assignment
|
||||
return { rootExpression, id }
|
||||
}
|
||||
|
||||
function createNode(
|
||||
@ -308,8 +313,9 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
expression: string,
|
||||
metadata: NodeMetadataFields = {},
|
||||
withImports: RequiredImport[] | undefined = undefined,
|
||||
documentation?: string | undefined,
|
||||
): Opt<NodeId> {
|
||||
return createNodes([{ position, expression, metadata, withImports }])[0]
|
||||
return createNodes([{ position, expression, metadata, withImports, documentation }])[0]
|
||||
}
|
||||
|
||||
/* Try adding imports. Does nothing if conflict is detected, and returns `DectedConflict` in such case. */
|
||||
|
@ -867,16 +867,26 @@ test.each([
|
||||
['\\x20', ' ', ' '],
|
||||
['\\b', '\b'],
|
||||
['abcdef_123', 'abcdef_123'],
|
||||
['\\t\\r\\n\\v\\"\\\'\\`', '\t\r\n\v"\'`'],
|
||||
['\\u00B6\\u{20}\\U\\u{D8\\xBFF}', '\xB6 \0\xD8\xBFF}', '\xB6 \\0\xD8\xBFF}'],
|
||||
["\\t\\r\\n\\v\\'\\`", "\t\r\n\v'`", "\\t\\r\\n\\v\\'\\`"],
|
||||
// Escaping a double quote is allowed, but not necessary.
|
||||
['\\"', '"', '"'],
|
||||
// Undefined/malformed escape sequences are left unevaluated, and properly escaped when normalized.
|
||||
['\\q\\u', '\\q\\u', '\\\\q\\\\u'],
|
||||
['\\u00B6\\u{20}\\U\\u{D8\\xBFF}', '\xB6 \\U\xD8\xBFF}', '\xB6 \\\\U\xD8\xBFF}'],
|
||||
['\\`foo\\` \\`bar\\` \\`baz\\`', '`foo` `bar` `baz`'],
|
||||
// Enso source code must be valid UTF-8 (per the specification), so Unicode unpaired surrogates must be escaped.
|
||||
['\\uDEAD', '\uDEAD', '\\u{dead}'],
|
||||
])(
|
||||
'Applying and escaping text literal interpolation',
|
||||
(escapedText: string, rawText: string, roundtrip?: string) => {
|
||||
(escapedText: string, rawText: string, normalizedEscapedText?: string) => {
|
||||
if (normalizedEscapedText != null) {
|
||||
// If `normalizedEscapedText` is provided, it must be a representation of the same raw value as `escapedText`.
|
||||
const rawTextFromNormalizedInput = unescapeTextLiteral(normalizedEscapedText)
|
||||
expect(rawTextFromNormalizedInput).toBe(rawText)
|
||||
}
|
||||
const actualApplied = unescapeTextLiteral(escapedText)
|
||||
const actualEscaped = escapeTextLiteral(rawText)
|
||||
|
||||
expect(actualEscaped).toBe(roundtrip ?? escapedText)
|
||||
expect(actualEscaped).toBe(normalizedEscapedText ?? escapedText)
|
||||
expect(actualApplied).toBe(rawText)
|
||||
},
|
||||
)
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { assert, assertDefined } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MutableModule, isIdentifier } from '@/util/ast/abstract'
|
||||
import { zipLongest } from '@/util/data/iterable'
|
||||
|
||||
export class Pattern {
|
||||
@ -22,8 +21,8 @@ export class Pattern {
|
||||
}
|
||||
|
||||
static new(f: (placeholder: Ast.Owned) => Ast.Owned, placeholder: string = '__'): Pattern {
|
||||
assert(isIdentifier(placeholder))
|
||||
const module = MutableModule.Transient()
|
||||
assert(Ast.isIdentifier(placeholder))
|
||||
const module = Ast.MutableModule.Transient()
|
||||
return new Pattern(f(Ast.Ident.new(module, placeholder)), placeholder)
|
||||
}
|
||||
|
||||
@ -45,7 +44,7 @@ export class Pattern {
|
||||
}
|
||||
|
||||
/** Create a new concrete example of the pattern, with the placeholders replaced with the given subtrees. */
|
||||
instantiate(edit: MutableModule, subtrees: Ast.Owned[]): Ast.Owned {
|
||||
instantiate(edit: Ast.MutableModule, subtrees: Ast.Owned[]): Ast.Owned {
|
||||
const template = edit.copy(this.template)
|
||||
const placeholders = findPlaceholders(template, this.placeholder).map((ast) => edit.tryGet(ast))
|
||||
for (const [placeholder, replacement] of zipLongest(placeholders, subtrees)) {
|
||||
@ -56,8 +55,8 @@ export class Pattern {
|
||||
return template
|
||||
}
|
||||
|
||||
instantiateCopied(subtrees: Ast.Ast[], edit?: MutableModule): Ast.Owned {
|
||||
const module = edit ?? MutableModule.Transient()
|
||||
instantiateCopied(subtrees: Ast.Ast[], edit?: Ast.MutableModule): Ast.Owned {
|
||||
const module = edit ?? Ast.MutableModule.Transient()
|
||||
return this.instantiate(
|
||||
module,
|
||||
subtrees.map((ast) => module.copy(ast)),
|
||||
@ -65,7 +64,7 @@ export class Pattern {
|
||||
}
|
||||
|
||||
compose(f: (pattern: Ast.Owned) => Ast.Owned): Pattern {
|
||||
const module = MutableModule.Transient()
|
||||
const module = Ast.MutableModule.Transient()
|
||||
return new Pattern(f(module.copy(this.template)), this.placeholder)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user