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:
Kaz Wesley 2024-04-19 12:33:51 -04:00 committed by GitHub
parent 7c571bd460
commit 6426478c97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 394 additions and 153 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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. */

View File

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

View File

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