diff --git a/app/gui2/e2e/collapsingAndEntering.spec.ts b/app/gui2/e2e/collapsingAndEntering.spec.ts index e7830945924d..2949ad5c930c 100644 --- a/app/gui2/e2e/collapsingAndEntering.spec.ts +++ b/app/gui2/e2e/collapsingAndEntering.spec.ts @@ -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) diff --git a/app/gui2/e2e/componentBrowser.spec.ts b/app/gui2/e2e/componentBrowser.spec.ts index 2249d3a663ea..5fef700e1c52 100644 --- a/app/gui2/e2e/componentBrowser.spec.ts +++ b/app/gui2/e2e/componentBrowser.spec.ts @@ -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) { diff --git a/app/gui2/e2e/keyboard.ts b/app/gui2/e2e/keyboard.ts new file mode 100644 index 000000000000..56cf175f16d4 --- /dev/null +++ b/app/gui2/e2e/keyboard.ts @@ -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' diff --git a/app/gui2/e2e/nodeClipboard.spec.ts b/app/gui2/e2e/nodeClipboard.spec.ts new file mode 100644 index 000000000000..f2cacd97a376 --- /dev/null +++ b/app/gui2/e2e/nodeClipboard.spec.ts @@ -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 { + 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) +}) diff --git a/app/gui2/e2e/undoRedo.spec.ts b/app/gui2/e2e/undoRedo.spec.ts index 533d0c7ae280..76179f66412d 100644 --- a/app/gui2/e2e/undoRedo.spec.ts +++ b/app/gui2/e2e/undoRedo.spec.ts @@ -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() }) diff --git a/app/gui2/shared/ast/text.ts b/app/gui2/shared/ast/text.ts index 3ce13d355958..cfdb87a440a8 100644 --- a/app/gui2/shared/ast/text.ts +++ b/app/gui2/shared/ast/text.ts @@ -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 } diff --git a/app/gui2/shared/ast/tree.ts b/app/gui2/shared/ast/tree.ts index 84c57facbe5f..be7207fce4ea 100644 --- a/app/gui2/shared/ast/tree.ts +++ b/app/gui2/shared/ast/tree.ts @@ -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({ + position: null, + visualization: null, + colorOverride: null, +}) export type NodeMetadata = FixedMapView export type MutableNodeMetadata = FixedMap export function asNodeMetadata(map: Map): NodeMetadata { @@ -67,9 +73,6 @@ interface RawAstFields { metadata: FixedMap } export interface AstFields extends RawAstFields, LegalFieldContent {} -function allKeys(keys: Record): (keyof T)[] { - return Object.keys(keys) as any -} const astFieldKeys = allKeys({ id: null, type: null, @@ -96,6 +99,11 @@ export abstract class Ast { return metadata as FixedMapView } + /** 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 - 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 { function* fieldDataEntries(map: FixedMapView) { 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] + if (!astFieldKeys.has(entry[0])) yield entry as [string, DeepReadonly] } } @@ -2413,6 +2427,7 @@ export interface FixedMapView { entries(): IterableIterator clone(): FixedMap has(key: string): boolean + toJSON(): object } export interface FixedMap extends FixedMapView { diff --git a/app/gui2/shared/util/types.ts b/app/gui2/shared/util/types.ts new file mode 100644 index 000000000000..0b311900ecd5 --- /dev/null +++ b/app/gui2/shared/util/types.ts @@ -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(keys: { [P in keyof T]-?: any }): ReadonlySet { + return Object.freeze(new Set(Object.keys(keys))) +} diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index d0f2f8c8b8a2..654f884b599a 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -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 diff --git a/app/gui2/src/components/GraphEditor/__tests__/clipboard.test.ts b/app/gui2/src/components/GraphEditor/__tests__/clipboard.test.ts new file mode 100644 index 000000000000..c673a476db69 --- /dev/null +++ b/app/gui2/src/components/GraphEditor/__tests__/clipboard.test.ts @@ -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 + + constructor(private data: Record) { + 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) + }) + }, +) diff --git a/app/gui2/src/components/GraphEditor/clipboard.ts b/app/gui2/src/components/GraphEditor/clipboard.ts index 08f9efcc85b4..08872144af88 100644 --- a/app/gui2/src/components/GraphEditor/clipboard.ts +++ b/app/gui2/src/components/GraphEditor/clipboard.ts @@ -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,107 +21,146 @@ 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 } -export function useGraphEditorClipboard( - nodeSelection: GraphSelection, - graphNavigator: NavigatorComposable, -) { - const graphStore = useGraphStore() +function nodeStructuredData(node: Node): CopiedNode { + return { + expression: node.innerExpr.code(), + documentation: node.documentation, + metadata: node.rootExpr.serializeMetadata(), + } +} - /** 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 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 { + 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 + } } - 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({ + } + 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>$/ + +async function nodeDataFromExcelClipboard( + htmlContent: string, + clipboardItem: ClipboardItem, +): Promise { + // 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) => 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, - }) - navigator.clipboard.write([clipboardItem]) - } + }), + ] +} - async function retrieveDataFromClipboard(): Promise { - 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()) - } +function getClipboard() { + return (window.navigator as any).mockClipboard ?? window.navigator.clipboard +} - 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 - } - } +export function useGraphEditorClipboard( + nodeSelection: GraphSelection, + graphNavigator: NavigatorComposable, +) { + const graphStore = useGraphStore() - 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 - } - } + /** Copy the content of the selected node to the clipboard. */ + function copySelectionToClipboard() { + const nodes = new Array() + for (const id of nodeSelection.selected) { + const node = graphStore.db.nodeIdToNode.get(id) + if (!node) continue + nodes.push(node) } - return fallback + if (!nodes.length) return + getClipboard() + .write(nodesToClipboardData(nodes)) + .catch((error: any) => console.error(`Failed to write to clipboard: ${error}`)) } - /// 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.') - } - graphStore.createNode( - graphNavigator.sceneMousePos ?? Vec2.Zero, - copiedNode.expression, - copiedNode.metadata, - ) - } - - async function readNodeFromExcelClipboard( - htmlContent: string, - clipboardItem: ClipboardItem, - ): Promise { - // 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('
') - ) { - 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 + for (const copiedNode of clipboardData) { + const { expression, documentation, metadata } = copiedNode + graphStore.createNode( + (clipboardData.length === 1 ? graphNavigator.sceneMousePos : null) ?? Vec2.Zero, + expression, + metadata, + undefined, + documentation, + ) } - return undefined } return { - copyNodeContent, - readNodeFromClipboard, + copySelectionToClipboard, + createNodesFromClipboard, } } diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 4180fcf41b44..026b9f22a752 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -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 { - 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. */ diff --git a/app/gui2/src/util/ast/__tests__/abstract.test.ts b/app/gui2/src/util/ast/__tests__/abstract.test.ts index 77fa0e0d216e..5c9f817b0976 100644 --- a/app/gui2/src/util/ast/__tests__/abstract.test.ts +++ b/app/gui2/src/util/ast/__tests__/abstract.test.ts @@ -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) }, ) diff --git a/app/gui2/src/util/ast/match.ts b/app/gui2/src/util/ast/match.ts index a9ff19965a98..3ece6c36adcc 100644 --- a/app/gui2/src/util/ast/match.ts +++ b/app/gui2/src/util/ast/match.ts @@ -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) } }