mirror of
https://github.com/enso-org/enso.git
synced 2024-09-11 13:15:52 +03:00
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:
parent
f1825f3f32
commit
76fb9f5c4b
@ -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>
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
49
app/gui2/src/components/PlusButton.vue
Normal file
49
app/gui2/src/components/PlusButton.vue
Normal 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>
|
20
app/gui2/src/composables/doubleClick.ts
Normal file
20
app/gui2/src/composables/doubleClick.ts
Normal 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 }
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user