Implement node ports below visualizations (#10113)

fixes #9830, #10108

![image](https://github.com/enso-org/enso/assets/919491/8ae50912-1dfb-4389-b51a-44022b334b89)
This commit is contained in:
Paweł Grabarz 2024-05-29 15:20:53 +02:00 committed by GitHub
parent 56b289ae79
commit 322662ee9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 176 additions and 84 deletions

View File

@ -1824,7 +1824,7 @@ applyMixins(MutableNumericLiteral, [MutableAst])
* GUI. We just need to represent them faithfully and create the simple cases. */
type ArgumentDefinition<T extends TreeRefs = RawRefs> = (T['ast'] | T['token'])[]
interface FunctionFields {
export interface FunctionFields {
name: NodeChild<AstId>
argumentDefinitions: ArgumentDefinition[]
equals: NodeChild<SyncTokenId>

View File

@ -19,6 +19,11 @@ const props = defineProps<{
maskSource?: boolean
}>()
// The padding added around the masking rect for nodes with visible output port. The actual padding
// is animated together with node's port opening. Required to correctly not draw the edge in space
// between the port path and node.
const VISIBLE_PORT_MASK_PADDING = 6
const base = ref<SVGPathElement>()
const mouseAnchor = computed(() => 'anchor' in props.edge && props.edge.anchor.type === 'mouse')
@ -109,11 +114,18 @@ type NodeMask = {
rect: Rect
radius: number
}
const sourceMask = computed<NodeMask | undefined>(() => {
if (!props.maskSource) return
const rect = sourceNodeRect.value
if (!rect) return
const radius = 16
const startsInPort = currentJunctionPoints.value?.startsInPort
if (!props.maskSource && !startsInPort) return
const nodeRect = sourceNodeRect.value
if (!nodeRect) return
const animProgress =
startsInPort ? (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)
const radius = 16 + padding
const id = `mask_for_edge_to-${props.edge.target ?? 'unconnected'}`
return { id, rect, radius }
})
@ -138,6 +150,7 @@ interface Inputs {
interface JunctionPoints {
points: Vec2[]
maxRadius: number
startsInPort: boolean
}
function circleIntersection(x: number, r1: number, r2: number): number {
@ -198,6 +211,11 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * (theme.edge.radius - theme.edge.three_corner.max_squeeze)
const horizontalRoomFor3CornersNoSqueeze =
targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * theme.edge.radius + theme.edge.three_corner.radius_max
if (targetWellBelowSource || (targetBelowSource && !horizontalRoomFor3Corners)) {
const innerTheme = theme.edge.one_corner
// The edge can originate anywhere along the length of the node.
@ -214,11 +232,13 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
const radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0)
const maxRadius = Math.min(radiusX, radiusY)
// The radius the edge would have, if the arc portion were as large as possible.
const offsetX = Math.abs(inputs.targetOffset.x - sourceX)
const naturalRadius = Math.min(
Math.abs(inputs.targetOffset.x - sourceX),
Math.abs(inputs.targetOffset.y),
)
let sourceDY = 0
let startsInPort = true
if (naturalRadius > innerTheme.minimum_tangent_exit_radius) {
// Offset the beginning of the edge so that it is normal to the curve of the source node
// at the point that it exits the node.
@ -229,12 +249,14 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
const intersection = circleIntersection(circleOffset, theme.node.corner_radius, radius)
sourceDY = -Math.abs(radius - intersection)
} else if (halfSourceSize.y != 0) {
sourceDY = -innerTheme.source_node_overlap + halfSourceSize.y
sourceDY = 0 - innerTheme.source_node_overlap
startsInPort = offsetX < innerTheme.minimum_tangent_exit_radius
}
const source = new Vec2(sourceX, sourceDY)
return {
points: [source, inputs.targetOffset],
maxRadius,
startsInPort,
}
} else {
const radiusMax = theme.edge.three_corner.radius_max
@ -283,6 +305,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
return {
points: [source, j0, j1, attachmentTarget],
maxRadius: radiusMax,
startsInPort: horizontalRoomFor3CornersNoSqueeze,
}
}
}
@ -349,25 +372,41 @@ function render(sourcePos: Vec2, elements: Element[]): string {
return out
}
const sourceOriginPoint = computed(() => {
const source = sourceRect.value
if (source == null) return null
const sourceStartPosY = Math.max(
source.top + theme.node.corner_radius,
source.bottom - theme.node.corner_radius,
)
return new Vec2(source.center().x, sourceStartPosY)
})
const currentJunctionPoints = computed(() => {
const target = targetPos.value
const source = sourceRect.value
if (target == null || source == null) return null
const inputs: Inputs = {
const origin = sourceOriginPoint.value
if (target == null || source == null || origin == null) return null
return junctionPoints({
sourceSize: source.size,
targetOffset: target.sub(source.center()),
}
return junctionPoints(inputs)
targetOffset: target.sub(origin),
})
})
const basePathElements = computed(() => {
const jp = currentJunctionPoints.value
if (jp == null) return undefined
return pathElements(jp)
})
const basePath = computed(() => {
if (props.edge.source == null && props.edge.target == null) return undefined
const jp = currentJunctionPoints.value
if (jp == null) return undefined
const { start, elements } = pathElements(jp)
const source_ = sourceRect.value
if (source_ == null) return undefined
return render(source_.center().add(start), elements)
const pathElements = basePathElements.value
if (!pathElements) return
const { start, elements } = pathElements
const origin = sourceOriginPoint.value
if (origin == null) return undefined
return render(origin.add(start), elements)
})
const activePath = computed(() => {
@ -449,11 +488,11 @@ const backwardEdgeArrowTransform = computed<string | undefined>(() => {
const points = currentJunctionPoints.value?.points
if (points == null || points.length < 3) return
const target = targetPos.value
const source = sourceRect.value
if (target == null || source == null) return
if (target.y > source.pos.y - theme.edge.three_corner.backward_edge_arrow_threshold) return
const origin = sourceOriginPoint.value
if (target == null || origin == null) return
if (target.y > origin.y - theme.edge.three_corner.backward_edge_arrow_threshold) return
if (points[1] == null) return
return svgTranslate(source.center().add(points[1]))
return svgTranslate(origin.add(points[1]))
})
const targetIsSelfArgument = computed(() => {

View File

@ -34,7 +34,16 @@ import { displayedIconOf } from '@/util/getIconName'
import { setIfUndefined } from 'lib0/map'
import type { ExternalId, VisualizationIdentifier } from 'shared/yjsModel'
import type { EffectScope } from 'vue'
import { computed, effectScope, onScopeDispose, onUnmounted, ref, watch, watchEffect } from 'vue'
import {
computed,
effectScope,
onScopeDispose,
onUnmounted,
ref,
shallowRef,
watch,
watchEffect,
} from 'vue'
const MAXIMUM_CLICK_LENGTH_MS = 300
const MAXIMUM_CLICK_DISTANCE_SQ = 50
@ -62,6 +71,7 @@ const emit = defineEmits<{
setNodeColor: [color: string | undefined]
'update:edited': [cursorPosition: number]
'update:rect': [rect: Rect]
'update:hoverAnim': [progress: number]
'update:visualizationId': [id: Opt<VisualizationIdentifier>]
'update:visualizationRect': [rect: Rect | undefined]
'update:visualizationVisible': [visible: boolean]
@ -225,13 +235,6 @@ const visualizationHeight = computed(() => props.node.vis?.height ?? null)
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
const isVisualizationFullscreen = computed(() => props.node.vis?.fullscreen ?? false)
watchEffect(() => {
const size = nodeSize.value
if (!size.isZero()) {
emit('update:rect', new Rect(props.node.position, nodeSize.value))
}
})
const bgStyleVariables = computed(() => {
const { x: width, y: height } = nodeSize.value
return {
@ -401,6 +404,23 @@ const outputPorts = computed((): PortData[] => {
})
const outputHovered = ref<AstId>()
const anyPortDisconnected = computed(() => {
for (const port of outputPortsSet.value) {
if (graph.disconnectedEdgePorts.has(port)) return true
}
return false
})
const portsVisible = computed(
() =>
selectionVisible.value ||
(outputHovered.value && outputPortsSet.value.has(outputHovered.value)) ||
anyPortDisconnected.value,
)
const portsHoverAnimation = useApproach(() => (portsVisible.value ? 1 : 0), 50, 0.01)
watchEffect(() => emit('update:hoverAnim', portsHoverAnimation.value))
const hoverAnimations = new Map<AstId, [ReturnType<typeof useApproach>, EffectScope]>()
watchEffect(() => {
const ports = outputPortsSet.value
@ -415,18 +435,7 @@ watchEffect(() => {
// the setup top-level), we need to create a detached scope for each invocation.
const scope = effectScope(true)
const approach = scope.run(() =>
useApproach(
() =>
(
outputHovered.value === port ||
graph.disconnectedEdgeTargets.has(port) ||
selectionVisible.value
) ?
1
: 0,
50,
0.01,
),
useApproach(() => (outputHovered.value === port ? 1 : 0), 50, 0.01),
)!
return [approach, scope]
})
@ -438,17 +447,40 @@ onScopeDispose(() => hoverAnimations.forEach(([_, scope]) => scope.stop()))
function portGroupStyle(port: PortData) {
const [start, end] = port.clipRange
const visBelowNode = graphSelectionSize.value.y - nodeSize.value.y
return {
'--hover-animation': hoverAnimations.get(port.portId)?.[0].value ?? 0,
'--hover-animation': portsHoverAnimation.value,
'--direct-hover-animation': hoverAnimations.get(port.portId)?.[0].value ?? 0,
'--port-clip-start': start,
'--port-clip-end': end,
transform: `translateY(${visBelowNode}px`,
}
}
const visRect = shallowRef<Rect>()
function updateVisualizationRect(rect: Rect | undefined) {
visRect.value = rect
emit('update:visualizationRect', rect)
}
const editingComment = ref(false)
const { getNodeColor, getNodeColors } = injectNodeColors()
const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
const graphSelectionSize = computed(() =>
isVisualizationVisible.value && visRect.value ? visRect.value.size : nodeSize.value,
)
const nodeRect = computed(() => new Rect(props.node.position, nodeSize.value))
const nodeOuterRect = computed(() =>
isVisualizationVisible.value && visRect.value ? visRect.value : nodeRect.value,
)
watchEffect(() => {
if (!nodeOuterRect.value.size.isZero()) {
emit('update:rect', nodeOuterRect.value)
}
})
</script>
<template>
@ -458,7 +490,7 @@ const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
class="GraphNode"
:style="{
transform,
minWidth: isVisualizationVisible ? `${visualizationWidth}px` : undefined,
minWidth: isVisualizationVisible ? `${visualizationWidth ?? 200}px` : undefined,
'--node-group-color': color,
...(node.zIndex ? { 'z-index': node.zIndex } : {}),
}"
@ -478,10 +510,11 @@ const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
v-if="navigator && !edited"
:class="{ dragged: isDragged }"
:nodePosition="props.node.position"
:nodeSize="nodeSize"
:nodeSize="graphSelectionSize"
:selected
:nodeId
:color
:externalHovered="nodeHovered"
@visible="selectionVisible = $event"
@pointerenter="updateSelectionHover"
@pointermove="updateSelectionHover"
@ -531,7 +564,7 @@ const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
:width="visualizationWidth"
:height="visualizationHeight"
:isFocused="isOnlyOneSelected"
@update:rect="emit('update:visualizationRect', $event)"
@update:rect="updateVisualizationRect"
@update:id="emit('update:visualizationId', $event)"
@update:visible="emit('update:visualizationVisible', $event)"
@update:fullscreen="emit('update:visualizationFullscreen', $event)"
@ -605,6 +638,7 @@ const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
display: flex;
--output-port-max-width: 4px;
--output-port-hovered-extra-width: 2px;
--output-port-overlap: -8px;
--output-port-hover-width: 20px;
}
@ -612,12 +646,14 @@ const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
.outputPort,
.outputPortHoverArea {
x: calc(0px - var(--output-port-width) / 2);
y: calc(0px - var(--output-port-width) / 2);
height: calc(var(--node-height) + var(--output-port-width));
width: calc(var(--node-width) + var(--output-port-width));
rx: calc(var(--node-border-radius) + var(--output-port-width) / 2);
fill: none;
stroke: var(--node-color-port);
stroke-width: calc(var(--output-port-width) + var(--output-port-overlap));
stroke-width: calc(var(--output-port-width) + var(--output-port-overlap-anim));
transition: stroke 0.2s ease;
--horizontal-line: calc(var(--node-width) - var(--node-border-radius) * 2);
--vertical-line: calc(var(--node-height) - var(--node-border-radius) * 2);
@ -633,23 +669,22 @@ const matchableNodeColors = getNodeColors((node) => node !== nodeId.value)
}
.outputPort {
--output-port-overlap-anim: calc(var(--hover-animation) * var(--output-port-overlap));
--output-port-width: calc(
var(--output-port-max-width) * var(--hover-animation) - var(--output-port-overlap)
var(--output-port-max-width) * var(--hover-animation) + var(--output-port-hovered-extra-width) *
var(--direct-hover-animation) - var(--output-port-overlap-anim)
);
y: calc(0px - var(--output-port-width) / 2);
height: calc(var(--node-height) + var(--output-port-width));
pointer-events: none;
}
.outputPortHoverArea {
--output-port-width: var(--output-port-hover-width);
y: calc(
0px + var(--output-port-hover-width) / 2 + var(--output-port-overlap) / 2 + var(--node-height) /
2
);
height: calc(var(--node-height) / 2 + var(--output-port-hover-width) / 2);
stroke-width: var(--output-port-hover-width);
stroke: transparent;
pointer-events: all;
/* Make stroke visible to debug the active area: */
/* stroke: red; */
stroke-linecap: butt;
pointer-events: stroke;
cursor: pointer;
}

View File

@ -8,6 +8,7 @@ const props = defineProps<{
nodeSize: Vec2
nodeId: AstId
selected: boolean
externalHovered: boolean
color: string
}>()
@ -16,7 +17,7 @@ const emit = defineEmits<{
}>()
const hovered = ref(false)
const visible = computed(() => props.selected || hovered.value)
const visible = computed(() => props.selected || props.externalHovered || hovered.value)
watchEffect(() => emit('visible', visible.value))

View File

@ -58,6 +58,7 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
@setNodeColor="emit('setNodeColor', $event)"
@update:edited="graphStore.setEditedNode(id, $event)"
@update:rect="graphStore.updateNodeRect(id, $event)"
@update:hoverAnim="graphStore.updateNodeHoverAnim(id, $event)"
@update:visualizationId="
graphStore.setNodeVisualization(id, $event != null ? { identifier: $event } : {})
"

View File

@ -41,8 +41,8 @@ import {
* - both of toolbars that are always visible (32px + 60px), and
* - the 4px flex gap between the toolbars. */
const MIN_WIDTH_PX = 200
const MIN_HEIGHT_PX = 16
const DEFAULT_HEIGHT_PX = 150
const MIN_CONTENT_HEIGHT_PX = 32
const DEFAULT_CONTENT_HEIGHT_PX = 150
const TOP_WITH_TOOLBAR_PX = 72
// Used for testing.
@ -236,24 +236,22 @@ watchEffect(async () => {
const isBelowToolbar = ref(false)
const toolbarHeight = computed(() => (isBelowToolbar.value ? TOP_WITH_TOOLBAR_PX : 0))
const rect = computed(
() =>
new Rect(
props.nodePosition,
new Vec2(
Math.max(props.width ?? MIN_WIDTH_PX, props.nodeSize.x),
Math.max(
props.height ?? DEFAULT_HEIGHT_PX,
(isBelowToolbar.value ? 0 : TOP_WITH_TOOLBAR_PX) + MIN_HEIGHT_PX,
),
Math.max(props.height ?? DEFAULT_CONTENT_HEIGHT_PX, MIN_CONTENT_HEIGHT_PX) +
toolbarHeight.value,
),
),
)
watchEffect(() => emit('update:rect', rect.value))
onUnmounted(() => {
emit('update:rect', undefined)
})
onUnmounted(() => emit('update:rect', undefined))
const allTypes = computed(() => Array.from(visualizationStore.types(props.typename)))
@ -277,7 +275,7 @@ provideVisualizationConfig({
emit('update:width', value)
},
get height() {
return rect.value.height
return rect.value.height - toolbarHeight.value
},
set height(value) {
emit('update:height', value)

View File

@ -95,4 +95,10 @@ const handler = {
.bottom.left {
cursor: nesw-resize;
}
.left,
.right,
.bottom {
z-index: 1;
}
</style>

View File

@ -3,7 +3,7 @@ import ResizeHandles from '@/components/ResizeHandles.vue'
import SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgButton from '@/components/SvgButton.vue'
import VisualizationSelector from '@/components/VisualizationSelector.vue'
import { isTriggeredByKeyboard, useResizeObserver } from '@/composables/events'
import { isTriggeredByKeyboard } from '@/composables/events'
import { useVisualizationConfig } from '@/providers/visualizationConfig'
import { Rect, type BoundsSet } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
@ -52,13 +52,13 @@ function hideSelector() {
requestAnimationFrame(() => (isSelectorVisible.value = false))
}
const realSize = useResizeObserver(contentNode)
const contentSize = computed(() => new Vec2(config.width, config.height))
// Because ResizeHandles are applying the screen mouse movements, the bouds must be in `screen`
// space.
const clientBounds = computed({
get() {
return new Rect(Vec2.Zero, realSize.value.scale(config.scale))
return new Rect(Vec2.Zero, contentSize.value.scale(config.scale))
},
set(value) {
if (resizing.left || resizing.right) config.width = value.width / config.scale
@ -68,9 +68,7 @@ const clientBounds = computed({
let resizing: BoundsSet = {}
// When dragging left resizer, we need to move node position accordingly. It may be done only by
// reading the real width change, as `config.width` does not consider node's minimum width.
watch(realSize, (newVal, oldVal) => {
watch(contentSize, (newVal, oldVal) => {
if (!resizing.left) return
const delta = newVal.x - oldVal.x
if (delta !== 0)
@ -86,8 +84,8 @@ const nodeShortType = computed(() =>
const contentStyle = computed(() => {
return {
width: config.fullscreen ? undefined : `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
height: config.fullscreen ? undefined : `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
width: config.fullscreen ? undefined : `${config.width}px`,
height: config.fullscreen ? undefined : `${config.height}px`,
}
})
</script>

View File

@ -179,7 +179,7 @@ export function useGraphHover(isPortEnabled: (port: PortId) => boolean) {
const hoveredNode = computed<NodeId | undefined>(() => {
const element = hoveredElement.value?.closest('.GraphNode')
if (!element) return undefined
return dataAttribute<NodeId>(element, 'node-id')
return dataAttribute<NodeId>(element, 'nodeId')
})
return { hoveredNode, hoveredPort }

View File

@ -18,7 +18,7 @@ export interface VisualizationConfig {
readonly isFocused: boolean
readonly nodeType: string | undefined
isBelowToolbar: boolean
width: number | null
width: number
height: number
nodePosition: Vec2
fullscreen: boolean

View File

@ -84,6 +84,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
)
const nodeRects = reactive(new Map<NodeId, Rect>())
const nodeHoverAnimations = reactive(new Map<NodeId, number>())
const vizRects = reactive(new Map<NodeId, Rect>())
// The currently visible nodes' areas (including visualization).
const visibleNodeAreas = computed(() => {
@ -246,22 +247,23 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
() => new Set(filterDefined([cbEditedEdge.value, mouseEditedEdge.value])),
)
const disconnectedEdgeTargets = computed(() => {
const targets = new Set<PortId>()
const disconnectedEdgePorts = computed(() => {
const ports = new Set<PortId>()
for (const edge of unconnectedEdges.value) {
if (edge.disconnectedEdgeTarget) targets.add(edge.disconnectedEdgeTarget)
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) targets.add(primarySubject)
if (primarySubject) ports.add(primarySubject)
}
return targets
return ports
})
const connectedEdges = computed(() => {
const edges = new Array<ConnectedEdge>()
for (const [target, sources] of db.connections.allReverse()) {
if (!disconnectedEdgeTargets.value.has(target)) {
if (!disconnectedEdgePorts.value.has(target)) {
for (const source of sources) {
edges.push({ source, target })
}
@ -354,6 +356,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
const outerExpr = edit.getVersion(node.outerExpr)
if (outerExpr) Ast.deleteFromParentBlock(outerExpr)
nodeRects.delete(id)
nodeHoverAnimations.delete(id)
}
},
true,
@ -464,6 +467,10 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
}
}
function updateNodeHoverAnim(nodeId: NodeId, progress: number) {
nodeHoverAnimations.set(nodeId, progress)
}
const nodesToPlace = reactive<NodeId[]>([])
const { place: placeNode } = usePlacement(visibleNodeAreas, Rect.Zero)
@ -726,10 +733,11 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
editedNodeInfo,
mouseEditedEdge,
cbEditedEdge,
disconnectedEdgeTargets,
disconnectedEdgePorts,
connectedEdges,
moduleSource,
nodeRects,
nodeHoverAnimations,
vizRects,
visibleNodeAreas,
visibleArea,
@ -752,6 +760,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
undoManager,
topLevel,
updateNodeRect,
updateNodeHoverAnim,
updateVizRect,
addPortInstance,
removePortInstance,

View File

@ -196,6 +196,11 @@ export class Rect {
height: this.size.y,
})
}
expand(padding: number): Rect {
const padVector = new Vec2(padding, padding)
return new Rect(this.pos.sub(padVector), this.size.add(padVector).add(padVector))
}
}
Rect.Zero = new Rect(Vec2.Zero, Vec2.Zero)