All ways of creating node (+ button, dragging out edges) (#8341)

Closes #8054

[Peek 2023-11-20 13-50.webm](https://github.com/enso-org/enso/assets/1428930/064ad921-25dc-44f4-bed4-10b4f0ec0242)
This commit is contained in:
Michael Mauderer 2023-11-23 17:47:53 +00:00 committed by GitHub
parent f1825f3f32
commit 76fb9f5c4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 193 additions and 38 deletions

View File

@ -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<HTMLElement>()
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<ExprId>): Environment {
const nodeRects = [...graphStore.nodeRects.values()]
const selectedNodeRects: Iterable<Rect> = [...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>(Vec2.Zero)
const graphEditorSourceNode = computed(() => {
function sourceNodeForSelection() {
if (graphStore.editedNodeInfo != null) return undefined
return nodeSelection.selected.values().next().value
})
}
const componentBrowserSourceNode = ref<ExprId | undefined>(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<Rect> = [...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)
}
</script>
<template>
@ -392,7 +444,7 @@ async function readNodeFromClipboard() {
<GraphEdges />
</svg>
<div :style="{ transform: graphNavigator.transform }" class="htmlLayer">
<GraphNodes />
<GraphNodes @nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick" />
</div>
<ComponentBrowser
v-if="componentBrowserVisible"
@ -404,7 +456,7 @@ async function readNodeFromClipboard() {
@canceled="onComponentBrowserCancel"
:initialContent="componentBrowserInputContent"
:initialCaretPosition="graphStore.editedNodeInfo?.range ?? [0, 0]"
:sourceNode="graphEditorSourceNode"
:sourceNode="componentBrowserSourceNode"
/>
<TopBar
v-model:mode="projectStore.executionMode"
@ -422,6 +474,7 @@ async function readNodeFromClipboard() {
</Suspense>
</Transition>
<GraphMouse />
<PlusButton @pointerdown="interaction.setCurrent(creatingNodeFromButton)" />
</div>
</template>

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
import GraphEdge from '@/components/GraphEditor/GraphEdge.vue'
import type { GraphNavigator } from '@/providers/graphNavigator.ts'
import { injectGraphSelection } from '@/providers/graphSelection.ts'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import { useGraphStore } from '@/stores/graph'
import { Vec2 } from '@/util/vec2.ts'
import type { ExprId } from 'shared/yjsModel.ts'
const graph = useGraphStore()
@ -17,7 +19,7 @@ const editingEdge: Interaction = {
graph.clearUnconnected()
})
},
click(_e: MouseEvent): boolean {
click(_e: MouseEvent, graphNavigator: GraphNavigator): boolean {
if (graph.unconnectedEdge == null) return false
const source = graph.unconnectedEdge.source ?? selection?.hoveredNode
const target = graph.unconnectedEdge.target ?? selection?.hoveredPort
@ -27,7 +29,7 @@ const editingEdge: Interaction = {
if (target == null) {
if (graph.unconnectedEdge?.disconnectedEdgeTarget != null)
disconnectEdge(graph.unconnectedEdge.disconnectedEdgeTarget)
createNodeFromEdgeDrop(source)
createNodeFromEdgeDrop(source, graphNavigator)
} else {
createEdge(source, target)
}
@ -43,8 +45,13 @@ function disconnectEdge(target: ExprId) {
graph.setExpressionContent(target, '_')
}
function createNodeFromEdgeDrop(source: ExprId) {
console.log(`TODO: createNodeFromEdgeDrop(${JSON.stringify(source)})`)
function createNodeFromEdgeDrop(source: ExprId, graphNavigator: GraphNavigator) {
const node = graph.createNodeFromSource(graphNavigator.sceneMousePos ?? Vec2.Zero, source)
if (node != null) {
graph.setEditedNode(node, 0)
} else {
console.error('Failed to create node from edge drop.')
}
}
function createEdge(source: ExprId, target: ExprId) {

View File

@ -4,6 +4,7 @@ import CircularMenu from '@/components/CircularMenu.vue'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import NodeWidgetTree from '@/components/GraphEditor/NodeWidgetTree.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useDoubleClick } from '@/composables/doubleClick'
import { injectGraphSelection } from '@/providers/graphSelection'
import { useGraphStore, type Node } from '@/stores/graph'
import { useApproach } from '@/util/animation'
@ -33,7 +34,8 @@ const emit = defineEmits<{
delete: []
replaceSelection: []
'update:selected': [selected: boolean]
outputPortAction: []
outputPortClick: []
outputPortDoubleClick: []
'update:edited': [cursorPosition: number]
}>()
@ -186,6 +188,13 @@ function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): numb
}
return 0
}
const handlePortClick = useDoubleClick(
() => emit('outputPortClick'),
() => {
emit('outputPortDoubleClick')
},
).handleClick
</script>
<template>
@ -240,7 +249,7 @@ function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): numb
class="outputPortHoverArea"
@pointerenter="outputHovered = true"
@pointerleave="outputHovered = false"
@pointerdown.stop.prevent="emit('outputPortAction')"
@pointerdown="handlePortClick()"
/>
<rect class="outputPort" />
<text class="outputTypeName">{{ outputTypeName }}</text>

View File

@ -18,6 +18,10 @@ const dragging = useDragging()
const selection = injectGraphSelection(true)
const navigator = injectGraphNavigator(true)
const emit = defineEmits<{
nodeOutputPortDoubleClick: [nodeId: ExprId]
}>()
function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
graphStore.transact(() => {
for (const [range, content] of updates) {
@ -59,7 +63,8 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
@setVisualizationVisible="graphStore.setNodeVisualizationVisible(id, $event)"
@dragging="nodeIsDragged(id, $event)"
@draggingCommited="dragging.finishDrag()"
@outputPortAction="graphStore.createEdgeFromOutput(id)"
@outputPortClick="graphStore.createEdgeFromOutput(id)"
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', id)"
/>
<UploadingFile
v-for="(nameAndFile, index) in uploadingFiles"

View File

@ -0,0 +1,49 @@
<template>
<div class="circle-plus">
<div class="vertical"></div>
<div class="horizontal"></div>
</div>
</template>
<script setup lang="ts"></script>
<style scoped>
.circle-plus {
position: absolute;
bottom: 10px;
left: 10px;
width: 60px;
height: 60px;
border-radius: 50%;
background-color: white;
}
.circle-plus:hover {
background: rgb(230, 230, 255);
}
.circle-plus:active {
background: rgb(158, 158, 255);
}
.vertical,
.horizontal {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgb(0, 115, 219);
margin: auto;
}
.vertical {
width: 4px;
height: 70%;
}
.horizontal {
width: 70%;
height: 4px;
}
</style>

View File

@ -0,0 +1,20 @@
export function useDoubleClick(onClick: Function, onDoubleClick: Function) {
const timeBetweenClicks = 200
let clickCount = 0
let singleClickTimer: ReturnType<typeof setTimeout>
const handleClick = () => {
clickCount++
if (clickCount === 1) {
onClick()
singleClickTimer = setTimeout(() => {
clickCount = 0
}, timeBetweenClicks)
} else if (clickCount === 2) {
clearTimeout(singleClickTimer)
clickCount = 0
onDoubleClick()
}
}
return { handleClick }
}

View File

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

View File

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