Output port interaction hint (#10264)

When hovering an output port, show a dimmed edge to the mouse.

https://github.com/enso-org/enso/assets/1047859/90b6e67e-9036-4eb0-bc18-9550d610c923

Closes #10195.
This commit is contained in:
Kaz Wesley 2024-06-12 10:57:25 -07:00 committed by GitHub
parent 3e37faa34d
commit aaf0e3da6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 201 additions and 129 deletions

View File

@ -51,8 +51,8 @@ test('Hover behaviour of edges', async ({ page }) => {
const edgeElements = await edgesFromNodeWithBinding(page, 'ten')
await expect(edgeElements).toHaveCount(EDGE_PARTS)
const targetEdge = edgeElements.first()
await expect(targetEdge).toHaveClass(/\bio\b/)
const targetEdge = edgeElements.and(page.locator('.io'))
await expect(targetEdge).toExist()
// Hover over edge to the left of node with binding `ten`.
await targetEdge.hover({

View File

@ -17,6 +17,7 @@ const graph = useGraphStore()
const props = defineProps<{
edge: Edge
maskSource?: boolean
animateFromSourceHover?: boolean
}>()
// The padding added around the masking rect for nodes with visible output port. The actual padding
@ -31,6 +32,8 @@ const mouseAnchorPos = computed(() => (mouseAnchor.value ? navigator?.sceneMouse
const hoveredNode = computed(() => (mouseAnchor.value ? selection?.hoveredNode : undefined))
const hoveredPort = computed(() => (mouseAnchor.value ? selection?.hoveredPort : undefined))
const isSuggestion = computed(() => 'suggestion' in props.edge && props.edge.suggestion)
const connectedSourceNode = computed(
() => props.edge.source && graph.db.getPatternExpressionNodeId(props.edge.source),
)
@ -115,13 +118,15 @@ type NodeMask = {
radius: number
}
const startsInPort = computed(() => currentJunctionPoints.value?.startsInPort)
const sourceMask = computed<NodeMask | undefined>(() => {
const startsInPort = currentJunctionPoints.value?.startsInPort
if (!props.maskSource && !startsInPort) return
if (!props.maskSource && !startsInPort.value) return
const nodeRect = sourceNodeRect.value
if (!nodeRect) return
const animProgress =
startsInPort ? (sourceNode.value && graph.nodeHoverAnimations.get(sourceNode.value)) ?? 0 : 0
startsInPort.value ?
(sourceNode.value && graph.nodeHoverAnimations.get(sourceNode.value)) ?? 0
: 0
let padding = animProgress * VISIBLE_PORT_MASK_PADDING
if (!props.maskSource && padding === 0) return
const rect = nodeRect.expand(padding)
@ -409,10 +414,9 @@ const basePath = computed(() => {
return render(origin.add(start), elements)
})
const activePath = computed(() => {
if (hovered.value && props.edge.source != null && props.edge.target != null) return basePath.value
else return undefined
})
const activePath = computed(
() => hovered.value && props.edge.source != null && props.edge.target != null,
)
function lengthTo(path: SVGPathElement, pos: Vec2): number {
const totalLength = path.getTotalLength()
@ -457,7 +461,6 @@ const activeStyle = computed(() => {
distances.mouseToTarget
: -distances.sourceToMouse
return {
...baseStyle.value,
strokeDasharray: distances.sourceToTarget,
strokeDashoffset: offset,
}
@ -475,6 +478,7 @@ const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan
function click(event: PointerEvent) {
const distances = mouseLocationOnEdge.value
if (distances == null) return
if (!isConnected(props.edge)) return
if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(props.edge, event)
else graph.disconnectSource(props.edge, event)
}
@ -528,7 +532,15 @@ const selfArgumentArrowPath = [
'Z',
].join('')
const connected = computed(() => isConnected(props.edge))
const sourceHoverAnimationStyle = computed(() => {
if (!props.animateFromSourceHover || !base.value || !sourceNode.value) return {}
const progress = graph.nodeHoverAnimations.get(sourceNode.value) ?? 0
if (progress === 1) return {}
const currentLength = progress * base.value.getTotalLength()
return {
strokeDasharray: `${currentLength}px 1000000px`,
}
})
</script>
<template>
@ -561,15 +573,16 @@ const connected = computed(() => isConnected(props.edge))
</mask>
<g v-bind="sourceMask && { mask: `url('#${sourceMask.id}')` }">
<path
v-if="activePath"
ref="base"
:d="basePath"
class="edge visible dimmed"
:style="baseStyle"
class="edge visible"
:class="{ dimmed: activePath || isSuggestion }"
:style="{ ...baseStyle, ...sourceHoverAnimationStyle }"
:data-source-node-id="sourceNode"
:data-target-node-id="targetNode"
/>
<path
v-if="connected"
v-if="isConnected(props.edge)"
:d="basePath"
class="edge io clickable"
:data-source-node-id="sourceNode"
@ -580,10 +593,10 @@ const connected = computed(() => isConnected(props.edge))
@pointerleave="hovered = false"
/>
<path
ref="base"
:d="activePath ?? basePath"
v-if="activePath"
:d="basePath"
class="edge visible"
:style="activePath ? activeStyle : baseStyle"
:style="{ ...baseStyle, ...activeStyle }"
:data-source-node-id="sourceNode"
:data-target-node-id="targetNode"
/>

View File

@ -120,21 +120,16 @@ function createEdge(source: AstId, target: PortId) {
<div>
<svg :viewBox="props.navigator.viewBox" class="overlay behindNodes">
<GraphEdge v-for="edge in graph.connectedEdges" :key="edge.target" :edge="edge" />
<GraphEdge v-if="graph.cbEditedEdge" :edge="graph.cbEditedEdge" />
<GraphEdge
v-if="graph.outputSuggestedEdge"
:edge="graph.outputSuggestedEdge"
animateFromSourceHover
/>
</svg>
<svg
v-if="graph.mouseEditedEdge"
:viewBox="props.navigator.viewBox"
class="overlay nonInteractive aboveNodes"
>
<svg v-if="graph.mouseEditedEdge" :viewBox="props.navigator.viewBox" class="overlay aboveNodes">
<GraphEdge :edge="graph.mouseEditedEdge" maskSource />
</svg>
<svg
v-if="graph.cbEditedEdge"
:viewBox="props.navigator.viewBox"
class="overlay nonInteractive behindNodes"
>
<GraphEdge :edge="graph.cbEditedEdge" />
</svg>
</div>
</template>

View File

@ -44,10 +44,13 @@ const outputPorts = computed((): PortData[] => {
// === Interactivity ===
const outputHovered = ref<AstId>()
const mouseOverOutput = ref<AstId>()
const outputHovered = computed(() => (graph.mouseEditedEdge ? undefined : mouseOverOutput.value))
const anyPortDisconnected = computed(() => {
for (const port of outputPortsSet.value) {
if (graph.disconnectedEdgePorts.has(port)) return true
if (graph.unconnectedEdgeSources.has(port)) return true
}
return false
})
@ -104,6 +107,8 @@ function portGroupStyle(port: PortData) {
transform: 'var(--output-port-transform)',
}
}
graph.suggestEdgeFromOutput(outputHovered)
</script>
<template>
@ -112,8 +117,8 @@ function portGroupStyle(port: PortData) {
<g class="portClip">
<rect
class="outputPortHoverArea clickable"
@pointerenter="outputHovered = port.portId"
@pointerleave="outputHovered = undefined"
@pointerenter="mouseOverOutput = port.portId"
@pointerleave="mouseOverOutput = undefined"
@pointerdown.stop.prevent="handlePortClick($event, port.portId)"
/>
<rect class="outputPort" />

View File

@ -12,6 +12,7 @@ import {
type Import,
type RequiredImport,
} from '@/stores/graph/imports'
import { useUnconnectedEdges, type UnconnectedEdge } from '@/stores/graph/unconnectedEdges'
import { type ProjectStore } from '@/stores/project'
import { type SuggestionDbStore } from '@/stores/suggestionDatabase'
import { assert, bail } from '@/util/assert'
@ -20,7 +21,6 @@ import type { AstId } from '@/util/ast/abstract'
import { MutableModule, isIdentifier, type Identifier } from '@/util/ast/abstract'
import { RawAst, visitRecursive } from '@/util/ast/raw'
import { partition } from '@/util/data/array'
import { filterDefined } from '@/util/data/iterable'
import { Rect } from '@/util/data/rect'
import { Err, Ok, mapOk, unwrap, type Result } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2'
@ -107,9 +107,6 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
const editedNodeInfo = ref<NodeEditInfo>()
const methodAst = ref<Result<Ast.Function>>(Err('AST not yet initialized'))
const mouseEditedEdge = ref<UnconnectedEdge & MouseEditedEdge>()
const cbEditedEdge = ref<UnconnectedTarget>()
const moduleSource = reactive(SourceDocument.Empty())
const moduleRoot = ref<Ast.Ast>()
const topLevel = ref<Ast.BodyBlock>()
@ -252,61 +249,28 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
}
const unconnectedEdges = computed(
() => new Set(filterDefined([cbEditedEdge.value, mouseEditedEdge.value])),
)
const unconnectedEdges = useUnconnectedEdges()
const disconnectedEdgePorts = computed(() => {
const ports = new Set<PortId>()
for (const edge of unconnectedEdges.value) {
if (edge.disconnectedEdgeTarget) ports.add(edge.disconnectedEdgeTarget)
if (edge.source) ports.add(edge.source)
}
if (editedNodeInfo.value) {
const primarySubject = db.nodeIdToNode.get(editedNodeInfo.value.id)?.primarySubject
if (primarySubject) ports.add(primarySubject)
}
return ports
})
const editedNodeDisconnectedTarget = computed(() =>
editedNodeInfo.value ?
db.nodeIdToNode.get(editedNodeInfo.value.id)?.primarySubject
: undefined,
)
const connectedEdges = computed(() => {
const edges = new Array<ConnectedEdge>()
for (const [target, sources] of db.connections.allReverse()) {
if (!disconnectedEdgePorts.value.has(target)) {
for (const source of sources) {
edges.push({ source, target })
if (target === editedNodeDisconnectedTarget.value) continue
for (const source of sources) {
const edge = { source, target }
if (!unconnectedEdges.isDisconnected(edge)) {
edges.push(edge)
}
}
}
return edges
})
function createEdgeFromOutput(source: Ast.AstId, event: PointerEvent | undefined) {
mouseEditedEdge.value = { source, target: undefined, event, anchor: { type: 'mouse' } }
}
function disconnectSource(edge: Edge, event: PointerEvent | undefined) {
if (!edge.target) return
mouseEditedEdge.value = {
source: undefined,
target: edge.target,
disconnectedEdgeTarget: edge.target,
event,
anchor: { type: 'mouse' },
}
}
function disconnectTarget(edge: Edge, event: PointerEvent | undefined) {
if (!edge.source || !edge.target) return
mouseEditedEdge.value = {
source: edge.source,
target: undefined,
disconnectedEdgeTarget: edge.target,
event,
anchor: { type: 'mouse' },
}
}
/* Try adding imports. Does nothing if conflict is detected, and returns `DectedConflict` in such case. */
function addMissingImports(
edit: MutableModule,
@ -760,10 +724,6 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
db: markRaw(db),
mockExpressionUpdate,
editedNodeInfo,
mouseEditedEdge,
cbEditedEdge,
disconnectedEdgePorts,
connectedEdges,
moduleSource,
nodeRects,
nodeHoverAnimations,
@ -774,9 +734,6 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
methodAst,
getMethodAst,
generateLocallyUniqueIdent,
createEdgeFromOutput,
disconnectSource,
disconnectTarget,
moduleRoot,
deleteNodes,
pickInCodeOrder,
@ -813,19 +770,16 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
return db.getExpressionInfo(currentMethod.expressionId)?.methodCall?.methodPointer
},
modulePath,
connectedEdges,
...unconnectedEdges,
})
},
)
interface AnyEdge {
source: AstId | undefined
target: PortId | undefined
}
/** An edge, which may be connected or unconnected. */
export type Edge = ConnectedEdge | UnconnectedEdge
export interface ConnectedEdge extends AnyEdge {
export interface ConnectedEdge {
source: AstId
target: PortId
}
@ -833,37 +787,3 @@ export interface ConnectedEdge extends AnyEdge {
export function isConnected(edge: Edge): edge is ConnectedEdge {
return edge.source != null && edge.target != null
}
type UnconnectedEdgeAnchor =
| {
type: 'mouse'
}
| {
type: 'fixed'
scenePos: Vec2
}
interface AnyUnconnectedEdge extends AnyEdge {
/** If this edge represents an in-progress edit of a connected edge, it is identified by its target expression. */
disconnectedEdgeTarget?: PortId
/** Identifies what the disconnected end should be attached to. */
anchor: UnconnectedEdgeAnchor
/** CSS value; if provided, overrides any color calculation. */
color?: string
}
interface UnconnectedSource extends AnyUnconnectedEdge {
source: undefined
target: PortId
}
interface UnconnectedTarget extends AnyUnconnectedEdge {
source: AstId
target: undefined
/** If true, the target end should be drawn as with a self-argument arrow. */
targetIsSelfArgument?: boolean
}
export type UnconnectedEdge = UnconnectedSource | UnconnectedTarget
interface MouseEditedEdge {
/** A pointer event which caused the unconnected edge */
event: PointerEvent | undefined
}

View File

@ -0,0 +1,139 @@
import type { PortId } from '@/providers/portInfo'
import type { ConnectedEdge } from '@/stores/graph/index'
import { filterDefined } from '@/util/data/iterable'
import { Vec2 } from '@/util/data/vec2'
import type { AstId } from 'shared/ast'
import { computed, ref, watch, type WatchSource } from 'vue'
export type UnconnectedEdgeAnchor = { type: 'mouse' } | { type: 'fixed'; scenePos: Vec2 }
interface AnyUnconnectedEdge {
source: AstId | undefined
target: PortId | undefined
/** If this edge represents an in-progress edit of a connected edge, it is identified by its target expression. */
disconnectedEdgeTarget?: PortId
/** Identifies what the disconnected end should be attached to. */
anchor: UnconnectedEdgeAnchor
/** CSS value; if provided, overrides any color calculation. */
color?: string
}
export interface UnconnectedSource extends AnyUnconnectedEdge {
source: undefined
target: PortId
}
export interface UnconnectedTarget extends AnyUnconnectedEdge {
source: AstId
target: undefined
/** If true, the target end should be drawn as with a self-argument arrow. */
targetIsSelfArgument?: boolean
/** If true, the edge will be rendered in its dimmed color. */
suggestion?: boolean
}
export type UnconnectedEdge = UnconnectedSource | UnconnectedTarget
export interface MouseEditedEdge {
/** A pointer event which caused the unconnected edge */
event: PointerEvent | undefined
}
export function useUnconnectedEdges() {
const mouseEditedEdge = ref<UnconnectedEdge & MouseEditedEdge>()
const cbEditedEdge = ref<UnconnectedTarget>()
const outputSuggestedEdge = ref<UnconnectedTarget>()
// === Mouse-edited edges ===
function createEdgeFromOutput(source: AstId, event: PointerEvent | undefined) {
mouseEditedEdge.value = { source, target: undefined, event, anchor: { type: 'mouse' } }
}
function disconnectSource(edge: ConnectedEdge, event: PointerEvent | undefined) {
mouseEditedEdge.value = {
source: undefined,
target: edge.target,
disconnectedEdgeTarget: edge.target,
event,
anchor: { type: 'mouse' },
}
}
function disconnectTarget(edge: ConnectedEdge, event: PointerEvent | undefined) {
mouseEditedEdge.value = {
source: edge.source,
target: undefined,
disconnectedEdgeTarget: edge.target,
event,
anchor: { type: 'mouse' },
}
}
// === Output-suggested edges ===
function startOutputSuggestedEdge(portId: AstId) {
outputSuggestedEdge.value = {
source: portId,
target: undefined,
anchor: { type: 'mouse' },
suggestion: true,
}
const createdEdge = outputSuggestedEdge.value
return {
endOutputSuggestedEdge: () => {
if (outputSuggestedEdge.value === createdEdge) outputSuggestedEdge.value = undefined
},
}
}
function suggestEdgeFromOutput(portId: WatchSource<AstId | undefined>) {
watch(portId, (portId, _prevPortId, onCleanup) => {
if (portId) {
const { endOutputSuggestedEdge } = startOutputSuggestedEdge(portId)
onCleanup(endOutputSuggestedEdge)
}
})
}
// === Edge status ===
const unconnectedEdges = computed<Set<UnconnectedEdge>>(
() =>
new Set(
filterDefined([mouseEditedEdge.value, cbEditedEdge.value, outputSuggestedEdge.value]),
),
)
const unconnectedEdgeSources = computed(() => {
const ports = new Set<AstId>()
for (const edge of unconnectedEdges.value) {
if (edge.source) ports.add(edge.source)
}
return ports
})
const disconnectedEdgeTargets = computed(() => {
const ports = new Set<PortId>()
for (const edge of unconnectedEdges.value) {
if (edge.disconnectedEdgeTarget) ports.add(edge.disconnectedEdgeTarget)
}
return ports
})
function isDisconnected(edge: ConnectedEdge): boolean {
return disconnectedEdgeTargets.value.has(edge.target)
}
return {
// === Special edges ===
mouseEditedEdge,
cbEditedEdge,
outputSuggestedEdge,
// === Edge creation ===
createEdgeFromOutput,
disconnectSource,
disconnectTarget,
suggestEdgeFromOutput,
// === Edge status ===
isDisconnected,
unconnectedEdgeSources,
}
}