mirror of
https://github.com/enso-org/enso.git
synced 2024-11-05 03:59:38 +03:00
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:
parent
3e37faa34d
commit
aaf0e3da6a
@ -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({
|
||||
|
@ -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"
|
||||
/>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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" />
|
||||
|
@ -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
|
||||
}
|
||||
|
139
app/gui2/src/stores/graph/unconnectedEdges.ts
Normal file
139
app/gui2/src/stores/graph/unconnectedEdges.ts
Normal 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,
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user