diff --git a/app/gui2/src/components/GraphEditor.vue b/app/gui2/src/components/GraphEditor.vue index d474e65d58..4ec79c41c6 100644 --- a/app/gui2/src/components/GraphEditor.vue +++ b/app/gui2/src/components/GraphEditor.vue @@ -4,10 +4,12 @@ import CodeEditor from '@/components/CodeEditor.vue' import ComponentBrowser from '@/components/ComponentBrowser.vue' import { mouseDictatedPlacement, + nonDictatedPlacement, previousNodeDictatedPlacement, type Environment, } from '@/components/ComponentBrowser/placement.ts' import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload' +import PlusButton from '@/components/PlusButton.vue' import TopBar from '@/components/TopBar.vue' import { provideGraphNavigator } from '@/providers/graphNavigator' import { provideGraphSelection } from '@/providers/graphSelection' @@ -31,6 +33,9 @@ const EXECUTION_MODES = ['design', 'live'] // Difference in position between the component browser and a node for the input of the component browser to // be placed at the same position as the node. const COMPONENT_BROWSER_TO_NODE_OFFSET = new Vec2(20, 35) +// Assumed size of a newly created node. This is used to place the component browser. +const DEFAULT_NODE_SIZE = new Vec2(0, 24) +const gapBetweenNodes = 48.0 const viewportNode = ref() const graphNavigator = provideGraphNavigator(viewportNode) @@ -51,37 +56,65 @@ const nodeSelection = provideGraphSelection(graphNavigator, graphStore.nodeRects const interactionBindingsHandler = interactionBindings.handler({ cancel: () => interaction.handleCancel(), - click: (e) => (e instanceof MouseEvent ? interaction.handleClick(e) : false), + click: (e) => (e instanceof MouseEvent ? interaction.handleClick(e, graphNavigator) : false), }) +// Return the environment for the placement of a new node. The passed nodes should be the nodes that are +// used as the source of the placement. This means, for example, the selected nodes when creating from a selection +// or the node that is being edited when creating from a port double click. +function environmentForNodes(nodeIds: IterableIterator): Environment { + const nodeRects = [...graphStore.nodeRects.values()] + const selectedNodeRects: Iterable = [...nodeIds] + .map((id) => graphStore.nodeRects.get(id)) + .filter((item): item is Rect => item !== undefined) + const screenBounds = graphNavigator.viewport + const mousePosition = graphNavigator.sceneMousePos + return { nodeRects, selectedNodeRects, screenBounds, mousePosition } as Environment +} + +const placementEnvironment = computed(() => { + return environmentForNodes(nodeSelection.selected.values()) +}) + +// Return the position for a new node, assuming there are currently nodes selected. If there are no nodes +// selected, return undefined. +function placementPositionForSelection() { + const hasNodeSelected = nodeSelection.selected.size > 0 + if (!hasNodeSelected) return undefined + const gapBetweenNodes = 48.0 + return previousNodeDictatedPlacement(DEFAULT_NODE_SIZE, placementEnvironment.value, { + horizontalGap: gapBetweenNodes, + verticalGap: gapBetweenNodes, + }).position +} + /** Where the component browser should be placed when it is opened. */ function targetComponentBrowserPosition() { const editedInfo = graphStore.editedNodeInfo const isEditingNode = editedInfo != null - const hasNodeSelected = nodeSelection.selected.size > 0 - const nodeSize = new Vec2(0, 24) if (isEditingNode) { const targetNode = graphStore.db.nodeIdToNode.get(editedInfo.id) const targetPos = targetNode?.position ?? Vec2.Zero return targetPos.add(COMPONENT_BROWSER_TO_NODE_OFFSET) - } else if (hasNodeSelected) { - const gapBetweenNodes = 48.0 - return previousNodeDictatedPlacement(nodeSize, placementEnvironment.value, { - horizontalGap: gapBetweenNodes, - verticalGap: gapBetweenNodes, - }).position } else { - return mouseDictatedPlacement(nodeSize, placementEnvironment.value).position + const targetPos = placementPositionForSelection() + if (targetPos != undefined) { + return targetPos + } else { + return mouseDictatedPlacement(DEFAULT_NODE_SIZE, placementEnvironment.value).position + } } } /** The current position of the component browser. */ const componentBrowserPosition = ref(Vec2.Zero) -const graphEditorSourceNode = computed(() => { +function sourceNodeForSelection() { if (graphStore.editedNodeInfo != null) return undefined return nodeSelection.selected.values().next().value -}) +} + +const componentBrowserSourceNode = ref(sourceNodeForSelection()) useEvent(window, 'keydown', (event) => { interactionBindingsHandler(event) || graphBindingsHandler(event) || codeEditorHandler(event) @@ -194,26 +227,31 @@ const editingNode: Interaction = { const nodeIsBeingEdited = computed(() => graphStore.editedNodeInfo != null) interaction.setWhen(nodeIsBeingEdited, editingNode) -const placementEnvironment = computed(() => { - const mousePosition = graphNavigator.sceneMousePos ?? Vec2.Zero - const nodeRects = [...graphStore.nodeRects.values()] - const selectedNodesIter = nodeSelection.selected.values() - const selectedNodeRects: Iterable = [...selectedNodesIter] - .map((id) => graphStore.nodeRects.get(id)) - .filter((item): item is Rect => item !== undefined) - const screenBounds = graphNavigator.viewport - const environment: Environment = { mousePosition, nodeRects, selectedNodeRects, screenBounds } - return environment -}) - const creatingNode: Interaction = { init: () => { componentBrowserInputContent.value = '' + componentBrowserSourceNode.value = sourceNodeForSelection() componentBrowserPosition.value = targetComponentBrowserPosition() componentBrowserVisible.value = true }, - cancel: () => { - // Nothing to do here. We just don't create a node and the component browser will close itself. +} + +const creatingNodeFromButton: Interaction = { + init: () => { + componentBrowserInputContent.value = '' + let targetPos = placementPositionForSelection() + if (targetPos == undefined) { + targetPos = nonDictatedPlacement(DEFAULT_NODE_SIZE, placementEnvironment.value).position + } + componentBrowserPosition.value = targetPos + componentBrowserVisible.value = true + }, +} + +const creatingNodeFromPortDoubleClick: Interaction = { + init: () => { + componentBrowserInputContent.value = '' + componentBrowserVisible.value = true }, } @@ -373,6 +411,20 @@ async function readNodeFromClipboard() { console.warn('No valid expression in clipboard.') } } + +function handleNodeOutputPortDoubleClick(id: ExprId) { + componentBrowserSourceNode.value = id + const placementEnvironment = environmentForNodes([id].values()) + componentBrowserPosition.value = previousNodeDictatedPlacement( + DEFAULT_NODE_SIZE, + placementEnvironment, + { + horizontalGap: gapBetweenNodes, + verticalGap: gapBetweenNodes, + }, + ).position + interaction.setCurrent(creatingNodeFromPortDoubleClick) +} diff --git a/app/gui2/src/components/GraphEditor/GraphEdges.vue b/app/gui2/src/components/GraphEditor/GraphEdges.vue index 3dc8ba32fb..b394cec622 100644 --- a/app/gui2/src/components/GraphEditor/GraphEdges.vue +++ b/app/gui2/src/components/GraphEditor/GraphEdges.vue @@ -1,8 +1,10 @@ + + + + diff --git a/app/gui2/src/composables/doubleClick.ts b/app/gui2/src/composables/doubleClick.ts new file mode 100644 index 0000000000..44a5095a41 --- /dev/null +++ b/app/gui2/src/composables/doubleClick.ts @@ -0,0 +1,20 @@ +export function useDoubleClick(onClick: Function, onDoubleClick: Function) { + const timeBetweenClicks = 200 + let clickCount = 0 + let singleClickTimer: ReturnType + + const handleClick = () => { + clickCount++ + if (clickCount === 1) { + onClick() + singleClickTimer = setTimeout(() => { + clickCount = 0 + }, timeBetweenClicks) + } else if (clickCount === 2) { + clearTimeout(singleClickTimer) + clickCount = 0 + onDoubleClick() + } + } + return { handleClick } +} diff --git a/app/gui2/src/providers/interactionHandler.ts b/app/gui2/src/providers/interactionHandler.ts index 8d0c34e075..bc09d8dc49 100644 --- a/app/gui2/src/providers/interactionHandler.ts +++ b/app/gui2/src/providers/interactionHandler.ts @@ -1,3 +1,4 @@ +import type { GraphNavigator } from '@/providers/graphNavigator.ts' import { watch, type WatchSource } from 'vue' import { createContextStore } from '.' @@ -42,13 +43,15 @@ export class InteractionHandler { return hasCurrent } - handleClick(event: MouseEvent): boolean | void { - return this.currentInteraction?.click ? this.currentInteraction.click(event) : false + handleClick(event: MouseEvent, graphNavigator: GraphNavigator): boolean | void { + return this.currentInteraction?.click + ? this.currentInteraction.click(event, graphNavigator) + : false } } export interface Interaction { cancel?(): void init?(): void - click?(event: MouseEvent): boolean | void + click?(event: MouseEvent, navigator: GraphNavigator): boolean | void } diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 46e6d2080a..44501d72df 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -176,6 +176,14 @@ export const useGraphStore = defineStore('graph', () => { return mod.insertNewNode(mod.doc.contents.length, ident, expression, meta) } + // Create a node from a source expression, and insert it into the graph. The return value will be + // the new node's ID, or `null` if the node creation fails. + function createNodeFromSource(position: Vec2, source: ExprId): Opt { + const sourceNodeName = db.getNodeMainOutputPortIdentifier(source) + const sourceNodeNameWithDot = sourceNodeName ? sourceNodeName + '.' : '' + return createNode(position, sourceNodeNameWithDot) + } + function deleteNode(id: ExprId) { const node = db.nodeIdToNode.get(id) if (node == null) return @@ -292,6 +300,7 @@ export const useGraphStore = defineStore('graph', () => { updateNodeRect, updateExprRect, setEditedNode, + createNodeFromSource, } })