From 4bf79776c5d04dbd1ecd12535f08e819d505609f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grabarz?= Date: Tue, 9 Apr 2024 14:02:11 +0200 Subject: [PATCH] Store graph viewport in client local storage (#9651) Fixes #6250 With this change, I've also slightly refactored the graph editor component by grouping related functionality into neat block and moving already loosely coupled groups to separate files. Further work will be needed to simplify it, but it is a good first step. https://github.com/enso-org/enso/assets/919491/fedce111-ea79-463f-a543-da3ecce28bf5 --- app/gui2/e2e/setup.ts | 2 +- app/gui2/e2e/undoRedo.spec.ts | 3 +- app/gui2/mock/index.ts | 1 - app/gui2/mock/providers.ts | 59 --- app/gui2/shared/ast/ffi.ts | 3 +- app/gui2/shared/languageServer.ts | 1 - app/gui2/shared/languageServerTypes.ts | 7 + app/gui2/src/components/GraphEditor.vue | 403 +++++++----------- .../src/components/GraphEditor/clipboard.ts | 120 ++++++ app/gui2/src/components/GraphEditor/toasts.ts | 30 ++ .../visualizations/TableVisualization.vue | 41 +- .../composables/__tests__/navigator.test.ts | 8 +- app/gui2/src/composables/animation.ts | 83 ++-- app/gui2/src/composables/navigator.ts | 82 ++-- app/gui2/src/composables/navigatorStorage.ts | 71 +++ app/gui2/src/providers/graphSelection.ts | 4 +- app/gui2/src/stores/graph/index.ts | 6 + app/gui2/src/stores/project/index.ts | 3 + app/gui2/src/util/data/iterable.ts | 6 + app/gui2/src/util/data/rect.ts | 4 + app/gui2/src/util/data/vec2.ts | 8 + app/gui2/stories/GraphNode.story.vue | 2 + 22 files changed, 550 insertions(+), 397 deletions(-) delete mode 100644 app/gui2/mock/providers.ts create mode 100644 app/gui2/src/components/GraphEditor/clipboard.ts create mode 100644 app/gui2/src/components/GraphEditor/toasts.ts create mode 100644 app/gui2/src/composables/navigatorStorage.ts diff --git a/app/gui2/e2e/setup.ts b/app/gui2/e2e/setup.ts index 3e574e59128..a9a5cd83a1b 100644 --- a/app/gui2/e2e/setup.ts +++ b/app/gui2/e2e/setup.ts @@ -1,5 +1,5 @@ import { Server } from '@open-rpc/server-js' -import * as random from 'lib0/random.js' +import * as random from 'lib0/random' import { methods as pmMethods, projects, diff --git a/app/gui2/e2e/undoRedo.spec.ts b/app/gui2/e2e/undoRedo.spec.ts index a9aefdf2e4f..533d0c7ae28 100644 --- a/app/gui2/e2e/undoRedo.spec.ts +++ b/app/gui2/e2e/undoRedo.spec.ts @@ -1,7 +1,6 @@ -import test, { type Locator, type Page } from 'playwright/test' +import test from 'playwright/test' import * as actions from './actions' import { expect } from './customExpect' -import { mockMethodCallInfo } from './expressionUpdates' import * as locate from './locate' test('Adding new node', async ({ page }) => { diff --git a/app/gui2/mock/index.ts b/app/gui2/mock/index.ts index 8f787fd65f9..7ae2a6716e3 100644 --- a/app/gui2/mock/index.ts +++ b/app/gui2/mock/index.ts @@ -9,7 +9,6 @@ import { MockTransport, MockWebSocket } from '@/util/net' import { getActivePinia } from 'pinia' import { ref, type App } from 'vue' import { mockDataHandler, mockLSHandler } from './engine' -export * as providers from './providers' export * as vue from './vue' export function languageServer() { diff --git a/app/gui2/mock/providers.ts b/app/gui2/mock/providers.ts deleted file mode 100644 index e8667ce2d6e..00000000000 --- a/app/gui2/mock/providers.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { GraphSelection } from '@/providers/graphSelection' -import type { GraphNavigator } from '../src/providers/graphNavigator' -import { Rect } from '../src/util/data/rect' -import { Vec2 } from '../src/util/data/vec2' - -export const graphNavigator: GraphNavigator = { - events: {} as any, - clientToScenePos: () => Vec2.Zero, - clientToSceneRect: () => Rect.Zero, - panAndZoomTo: () => {}, - panTo: () => {}, - scrollTo: () => {}, - stepZoom: () => {}, - transform: '', - prescaledTransform: '', - translate: Vec2.Zero, - targetScale: 1, - scale: 1, - sceneMousePos: Vec2.Zero, - viewBox: '', - viewport: Rect.Zero, -} - -export function graphNavigatorWith(modifications?: Partial): GraphNavigator { - return Object.assign({}, graphNavigator, modifications) -} - -export const graphSelection: GraphSelection = { - events: {} as any, - anchor: undefined, - deselectAll: () => {}, - handleSelectionOf: () => {}, - setSelection: () => {}, - hoveredNode: undefined, - hoveredPort: undefined, - isSelected: () => false, - isChanging: false, - mouseHandler: () => false, - selectAll: () => {}, - selected: new Set(), -} - -export function graphSelectionWith(modifications?: Partial): GraphSelection { - return Object.assign({}, graphSelection, modifications) -} - -export const all = { - 'graph navigator': graphNavigator, - 'graph selection': graphSelection, -} - -export function allWith( - modifications: Partial<{ [K in keyof typeof all]: Partial<(typeof all)[K]> }>, -): typeof all { - return { - 'graph navigator': graphNavigatorWith(modifications['graph navigator']), - 'graph selection': graphSelectionWith(modifications['graph selection']), - } -} diff --git a/app/gui2/shared/ast/ffi.ts b/app/gui2/shared/ast/ffi.ts index d83aa8df4d2..7319a4680be 100644 --- a/app/gui2/shared/ast/ffi.ts +++ b/app/gui2/shared/ast/ffi.ts @@ -1,10 +1,11 @@ import { createXXHash128 } from 'hash-wasm' +import type { IDataType } from 'hash-wasm/dist/lib/util' import init, { is_ident_or_operator, parse, parse_doc_to_json } from '../../rust-ffi/pkg/rust_ffi' import { assertDefined } from '../util/assert' import { isNode } from '../util/detect' let xxHasher128: Awaited> | undefined -export function xxHash128(input: string) { +export function xxHash128(input: IDataType) { assertDefined(xxHasher128, 'Module should have been loaded with `initializeFFI`.') xxHasher128.init() xxHasher128.update(input) diff --git a/app/gui2/shared/languageServer.ts b/app/gui2/shared/languageServer.ts index 0f2ee00b49c..75e577b0ff7 100644 --- a/app/gui2/shared/languageServer.ts +++ b/app/gui2/shared/languageServer.ts @@ -21,7 +21,6 @@ import type { VisualizationConfiguration, response, } from './languageServerTypes' -import type { AbortScope } from './util/net' import type { Uuid } from './yjsModel' const DEBUG_LOG_RPC = false diff --git a/app/gui2/shared/languageServerTypes.ts b/app/gui2/shared/languageServerTypes.ts index bcaa8ec7ad6..d31d8c395d3 100644 --- a/app/gui2/shared/languageServerTypes.ts +++ b/app/gui2/shared/languageServerTypes.ts @@ -1,3 +1,4 @@ +import * as encoding from 'lib0/encoding' import type { SuggestionsDatabaseEntry, SuggestionsDatabaseUpdate, @@ -363,6 +364,12 @@ export interface LocalCall { expressionId: ExpressionId } +export function encodeMethodPointer(enc: encoding.Encoder, ptr: MethodPointer) { + encoding.writeVarString(enc, ptr.module) + encoding.writeVarString(enc, ptr.name) + encoding.writeVarString(enc, ptr.definedOnType) +} + export function stackItemsEqual(left: StackItem, right: StackItem): boolean { if (left.type !== right.type) return false diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index 4f11c0257d4..f3b363615da 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -12,6 +12,7 @@ import GraphEdges from '@/components/GraphEditor/GraphEdges.vue' import GraphNodes from '@/components/GraphEditor/GraphNodes.vue' import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing' import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation' +import { useGraphEditorToasts } from '@/components/GraphEditor/toasts' import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload' import GraphMouse from '@/components/GraphMouse.vue' import PlusButton from '@/components/PlusButton.vue' @@ -19,6 +20,7 @@ import SceneScroller from '@/components/SceneScroller.vue' import TopBar from '@/components/TopBar.vue' import { useDoubleClick } from '@/composables/doubleClick' import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events' +import { useNavigatorStorage } from '@/composables/navigatorStorage' import { useStackNavigator } from '@/composables/stackNavigator' import { provideGraphNavigator } from '@/providers/graphNavigator' import { provideGraphSelection } from '@/providers/graphSelection' @@ -30,57 +32,64 @@ import type { RequiredImport } from '@/stores/graph/imports' import { useProjectStore } from '@/stores/project' import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase' import { assertNever, bail } from '@/util/assert' -import type { AstId, NodeMetadataFields } from '@/util/ast/abstract' +import type { AstId } from '@/util/ast/abstract' import type { Pattern } from '@/util/ast/match' import { colorFromString } from '@/util/colors' import { partition } from '@/util/data/array' +import { filterDefined } from '@/util/data/iterable' import { Rect } from '@/util/data/rect' import { Vec2 } from '@/util/data/vec2' -import { useToast } from '@/util/toast' -import * as set from 'lib0/set' +import { encoding, set } from 'lib0' +import { encodeMethodPointer } from 'shared/languageServerTypes' import { computed, onMounted, ref, toRef, watch } from 'vue' -import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/services/ProjectManager' import { type Usage } from './ComponentBrowser/input' +import { useGraphEditorClipboard } from './GraphEditor/clipboard' const keyboard = provideKeyboard() -const viewportNode = ref() -const graphNavigator = provideGraphNavigator(viewportNode, keyboard) const graphStore = useGraphStore() const widgetRegistry = provideWidgetRegistry(graphStore.db) widgetRegistry.loadBuiltins() const projectStore = useProjectStore() -const componentBrowserVisible = ref(false) -const componentBrowserNodePosition = ref(Vec2.Zero) -const componentBrowserUsage = ref({ type: 'newNode' }) const suggestionDb = useSuggestionDbStore() -const interaction = provideInteractionHandler() -// === toasts === +// === Navigator === -const toastStartup = useToast.info({ autoClose: false }) -const toastConnectionLost = useToast.error({ autoClose: false }) -const toastLspError = useToast.error() -const toastConnectionError = useToast.error() -const toastExecutionFailed = useToast.error() +const viewportNode = ref() +onMounted(() => viewportNode.value?.focus()) +const graphNavigator = provideGraphNavigator(viewportNode, keyboard) +useNavigatorStorage(graphNavigator, (enc) => { + // Navigator viewport needs to be stored separately for: + // - each project + // - each function within the project + encoding.writeVarString(enc, projectStore.name) + const methodPtr = graphStore.currentMethodPointer() + if (methodPtr != null) encodeMethodPointer(enc, methodPtr) +}) -toastStartup.show('Initializing the project. This can take up to one minute.') -projectStore.firstExecution.then(toastStartup.dismiss) +function zoomToSelected() { + if (!viewportNode.value) return -useEvent(document, ProjectManagerEvents.loadingFailed, () => - toastConnectionLost.show('Lost connection to Language Server.'), -) + const allNodes = graphStore.db.nodeIdToNode + const validSelected = [...nodeSelection.selected].filter((id) => allNodes.has(id)) + const nodesToCenter = validSelected.length === 0 ? allNodes.keys() : validSelected + let bounds = Rect.Bounding() + for (const id of nodesToCenter) { + const rect = graphStore.visibleArea(id) + if (rect) bounds = Rect.Bounding(bounds, rect) + } + if (bounds.isFinite()) + graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.targetScale)) +} -projectStore.lsRpcConnection.then( - (ls) => ls.client.onError((e) => toastLspError.show(`Language server error: ${e}`)), - (e) => toastConnectionError.show(`Connection to language server failed: ${JSON.stringify(e)}`), -) +// == Breadcrumbs == -projectStore.executionContext.on('executionComplete', () => toastExecutionFailed.dismiss()) -projectStore.executionContext.on('executionFailed', (e) => - toastExecutionFailed.show(`Execution Failed: ${JSON.stringify(e)}`), -) +const stackNavigator = useStackNavigator() -// === nodes === +// === Toasts === + +useGraphEditorToasts() + +// === Selection === const nodeSelection = provideGraphSelection( graphNavigator, @@ -93,6 +102,22 @@ const nodeSelection = provideGraphSelection( }, ) +// Clear selection whenever the graph view is switched. +watch( + () => projectStore.executionContext.getStackTop(), + () => nodeSelection.deselectAll(), +) + +// === Clipboard Copy/Paste === + +const { copyNodeContent, readNodeFromClipboard } = useGraphEditorClipboard( + nodeSelection, + graphNavigator, +) + +// === Interactions === + +const interaction = provideInteractionHandler() const interactionBindingsHandler = interactionBindings.handler({ cancel: () => interaction.handleCancel(), }) @@ -111,19 +136,7 @@ useEvent(window, 'pointerdown', (e) => interaction.handleClick(e, graphNavigator capture: true, }) -onMounted(() => viewportNode.value?.focus()) - -function zoomToSelected() { - if (!viewportNode.value) return - const nodesToCenter = - nodeSelection.selected.size === 0 ? graphStore.db.nodeIdToNode.keys() : nodeSelection.selected - let bounds = Rect.Bounding() - for (const id of nodesToCenter) { - const rect = graphStore.visibleArea(id) - if (rect) bounds = Rect.Bounding(bounds, rect) - } - graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.scale)) -} +// === Keyboard/Mouse bindings === const graphBindingsHandler = graphBindings.handler({ undo() { @@ -256,6 +269,9 @@ const { handleClick } = useDoubleClick( stackNavigator.exitNode() }, ) + +// === Code Editor === + const codeEditorArea = ref() const showCodeEditor = ref(false) const toggleCodeEditor = () => { @@ -267,6 +283,8 @@ const codeEditorHandler = codeEditorBindings.handler({ }, }) +// === Execution Mode === + /** Handle record-once button presses. */ function onRecordOnceButtonPress() { projectStore.lsRpcConnection.then(async () => { @@ -286,13 +304,11 @@ watch( }, ) -const groupColors = computed(() => { - const styles: { [key: string]: string } = {} - for (let group of suggestionDb.groups) { - styles[groupColorVar(group)] = group.color ?? colorFromString(group.name) - } - return styles -}) +// === Component Browser === + +const componentBrowserVisible = ref(false) +const componentBrowserNodePosition = ref(Vec2.Zero) +const componentBrowserUsage = ref({ type: 'newNode' }) function openComponentBrowser(usage: Usage, position: Vec2) { componentBrowserUsage.value = usage @@ -300,6 +316,11 @@ function openComponentBrowser(usage: Usage, position: Vec2) { componentBrowserVisible.value = true } +function hideComponentBrowser() { + graphStore.editedNodeInfo = undefined + componentBrowserVisible.value = false +} + function editWithComponentBrowser(node: NodeId, cursorPos: number) { openComponentBrowser( { type: 'editNode', node, cursorPos }, @@ -307,6 +328,68 @@ function editWithComponentBrowser(node: NodeId, cursorPos: number) { ) } +function createWithComponentBrowser(options: NewNodeOptions) { + openComponentBrowser( + { type: 'newNode', sourcePort: options.sourcePort }, + placeNode(options.placement), + ) +} + +function commitComponentBrowser(content: string, requiredImports: RequiredImport[]) { + if (content != null) { + if (graphStore.editedNodeInfo) { + // We finish editing a node. + graphStore.setNodeContent(graphStore.editedNodeInfo.id, content, requiredImports) + } else if (content != '') { + // We finish creating a new node. + const metadata = undefined + const createdNode = graphStore.createNode( + componentBrowserNodePosition.value, + content, + metadata, + requiredImports, + ) + if (createdNode) nodeSelection.setSelection(new Set([createdNode])) + } + } + hideComponentBrowser() +} + +// Watch the `editedNode` in the graph store and synchronize component browser display with it. +watch( + () => graphStore.editedNodeInfo, + (editedInfo) => { + if (editedInfo) { + editWithComponentBrowser(editedInfo.id, editedInfo.initialCursorPos) + } else { + hideComponentBrowser() + } + }, +) + +// === Node Creation === + +interface NewNodeOptions { + placement: PlacementType + sourcePort?: AstId | undefined +} +type PlacementType = 'viewport' | ['source', NodeId] | ['fixed', Vec2] + +const placeNode = (placement: PlacementType): Vec2 => + placement === 'viewport' ? nodePlacement().position + : placement[0] === 'source' ? + nodePlacement(filterDefined([graphStore.visibleArea(placement[1])])).position + : placement[0] === 'fixed' ? placement[1] + : assertNever(placement) + +/** + * Start creating a node, basing its inputs and position on the current selection, if any; + * or the current viewport, otherwise. + */ +function addNodeAuto() { + createWithComponentBrowser(fromSelection() ?? { placement: 'viewport' }) +} + function fromSelection(): NewNodeOptions | undefined { if (graphStore.editedNodeInfo != null) return undefined const firstSelectedNode = set.first(nodeSelection.selected) @@ -316,41 +399,10 @@ function fromSelection(): NewNodeOptions | undefined { } } -type PlacementType = 'viewport' | ['source', NodeId] | ['fixed', Vec2] - -function* filterDefined(iterable: Iterable): IterableIterator { - for (const value of iterable) { - if (value !== undefined) yield value - } -} - -const placeNode = (placement: PlacementType): Vec2 => - placement === 'viewport' ? nodePlacement().position - : placement[0] === 'source' ? - nodePlacement(filterDefined([graphStore.visibleArea(placement[1])])).position - : placement[0] === 'fixed' ? placement[1] - : assertNever(placement) - -interface NewNodeOptions { - placement: PlacementType - sourcePort?: AstId | undefined -} - -function createWithComponentBrowser(options: NewNodeOptions) { - openComponentBrowser( - { - type: 'newNode', - sourcePort: options.sourcePort, - }, - placeNode(options.placement), - ) -} - -/** Start creating a node, basing its inputs and position on the current selection, if any; - * or the current viewport, otherwise. - */ -function addNodeAuto() { - createWithComponentBrowser(fromSelection() ?? { placement: 'viewport' }) +function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) { + const position = placeNode(placement) + const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code() + return graphStore.createNode(position, content, undefined, []) ?? undefined } function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) { @@ -375,48 +427,20 @@ function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[ createWithComponentBrowser({ placement: placementForOptions(options), sourcePort }) } -function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) { - const position = placeNode(placement) - const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code() - return graphStore.createNode(position, content, undefined, []) ?? undefined -} - -function hideComponentBrowser() { - graphStore.editedNodeInfo = undefined - componentBrowserVisible.value = false -} - -function commitComponentBrowser(content: string, requiredImports: RequiredImport[]) { - if (content != null) { - if (graphStore.editedNodeInfo) { - // We finish editing a node. - graphStore.setNodeContent(graphStore.editedNodeInfo.id, content, requiredImports) - } else if (content != '') { - // We finish creating a new node. - const metadata = undefined - const createdNode = graphStore.createNode( - componentBrowserNodePosition.value, - content, - metadata, - requiredImports, - ) - if (createdNode) nodeSelection.setSelection(new Set([createdNode])) - } +function handleNodeOutputPortDoubleClick(id: AstId) { + const srcNode = graphStore.db.getPatternExpressionNodeId(id) + if (srcNode == null) { + console.error('Impossible happened: Double click on port not belonging to any node: ', id) + return } - hideComponentBrowser() + createWithComponentBrowser({ placement: ['source', srcNode], sourcePort: id }) } -// Watch the `editedNode` in the graph store -watch( - () => graphStore.editedNodeInfo, - (editedInfo) => { - if (editedInfo) { - editWithComponentBrowser(editedInfo.id, editedInfo.initialCursorPos) - } else { - hideComponentBrowser() - } - }, -) +function handleEdgeDrop(source: AstId, position: Vec2) { + createWithComponentBrowser({ placement: ['fixed', position], sourcePort: source }) +} + +// === Drag and drop === async function handleFileDrop(event: DragEvent) { // A vertical gap between created nodes when multiple files were dropped together. @@ -451,127 +475,6 @@ async function handleFileDrop(event: DragEvent) { }) } -// === Clipboard === - -const ENSO_MIME_TYPE = 'web application/enso' - -/** The data that is copied to the clipboard. */ -interface ClipboardData { - nodes: CopiedNode[] -} - -/** Node data that is copied to the clipboard. Used for serializing and deserializing the node information. */ -interface CopiedNode { - expression: string - metadata: NodeMetadataFields | undefined -} - -/** 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'), - } - 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]) -} - -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()) - } - - 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() { - let clipboardData = await retrieveDataFromClipboard() - if (!clipboardData) { - console.warn('No valid data in clipboard.') - return - } - const copiedNode = clipboardData.nodes[0] - if (!copiedNode) { - 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 - } - return undefined -} - -function handleNodeOutputPortDoubleClick(id: AstId) { - const srcNode = graphStore.db.getPatternExpressionNodeId(id) - if (srcNode == null) { - console.error('Impossible happened: Double click on port not belonging to any node: ', id) - return - } - createWithComponentBrowser({ placement: ['source', srcNode], sourcePort: id }) -} - -const stackNavigator = useStackNavigator() - -function handleEdgeDrop(source: AstId, position: Vec2) { - createWithComponentBrowser({ placement: ['fixed', position], sourcePort: source }) -} - // === Color Picker === /** A small offset to keep the color picker slightly away from the nodes. */ @@ -616,6 +519,14 @@ const colorPickerStyle = computed(() => { transform: `translate(${colorPickerPos.value.x}px, ${colorPickerPos.value.y}px)` } : {}, ) + +const groupColors = computed(() => { + const styles: { [key: string]: string } = {} + for (let group of suggestionDb.groups) { + styles[groupColorVar(group)] = group.color ?? colorFromString(group.name) + } + return styles +})