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

View File

@ -19,6 +19,11 @@ const props = defineProps<{
maskSource?: boolean 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 base = ref<SVGPathElement>()
const mouseAnchor = computed(() => 'anchor' in props.edge && props.edge.anchor.type === 'mouse') const mouseAnchor = computed(() => 'anchor' in props.edge && props.edge.anchor.type === 'mouse')
@ -109,11 +114,18 @@ type NodeMask = {
rect: Rect rect: Rect
radius: number radius: number
} }
const sourceMask = computed<NodeMask | undefined>(() => { const sourceMask = computed<NodeMask | undefined>(() => {
if (!props.maskSource) return const startsInPort = currentJunctionPoints.value?.startsInPort
const rect = sourceNodeRect.value if (!props.maskSource && !startsInPort) return
if (!rect) return const nodeRect = sourceNodeRect.value
const radius = 16 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'}` const id = `mask_for_edge_to-${props.edge.target ?? 'unconnected'}`
return { id, rect, radius } return { id, rect, radius }
}) })
@ -138,6 +150,7 @@ interface Inputs {
interface JunctionPoints { interface JunctionPoints {
points: Vec2[] points: Vec2[]
maxRadius: number maxRadius: number
startsInPort: boolean
} }
function circleIntersection(x: number, r1: number, r2: number): number { function circleIntersection(x: number, r1: number, r2: number): number {
@ -198,6 +211,11 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
targetBeyondSource && targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >= Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * (theme.edge.radius - theme.edge.three_corner.max_squeeze) 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)) { if (targetWellBelowSource || (targetBelowSource && !horizontalRoomFor3Corners)) {
const innerTheme = theme.edge.one_corner const innerTheme = theme.edge.one_corner
// The edge can originate anywhere along the length of the node. // 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 radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0)
const maxRadius = Math.min(radiusX, radiusY) const maxRadius = Math.min(radiusX, radiusY)
// The radius the edge would have, if the arc portion were as large as possible. // 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( const naturalRadius = Math.min(
Math.abs(inputs.targetOffset.x - sourceX), Math.abs(inputs.targetOffset.x - sourceX),
Math.abs(inputs.targetOffset.y), Math.abs(inputs.targetOffset.y),
) )
let sourceDY = 0 let sourceDY = 0
let startsInPort = true
if (naturalRadius > innerTheme.minimum_tangent_exit_radius) { 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 // 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. // 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) const intersection = circleIntersection(circleOffset, theme.node.corner_radius, radius)
sourceDY = -Math.abs(radius - intersection) sourceDY = -Math.abs(radius - intersection)
} else if (halfSourceSize.y != 0) { } 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) const source = new Vec2(sourceX, sourceDY)
return { return {
points: [source, inputs.targetOffset], points: [source, inputs.targetOffset],
maxRadius, maxRadius,
startsInPort,
} }
} else { } else {
const radiusMax = theme.edge.three_corner.radius_max const radiusMax = theme.edge.three_corner.radius_max
@ -283,6 +305,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
return { return {
points: [source, j0, j1, attachmentTarget], points: [source, j0, j1, attachmentTarget],
maxRadius: radiusMax, maxRadius: radiusMax,
startsInPort: horizontalRoomFor3CornersNoSqueeze,
} }
} }
} }
@ -349,25 +372,41 @@ function render(sourcePos: Vec2, elements: Element[]): string {
return out 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 currentJunctionPoints = computed(() => {
const target = targetPos.value const target = targetPos.value
const source = sourceRect.value const source = sourceRect.value
if (target == null || source == null) return null const origin = sourceOriginPoint.value
const inputs: Inputs = { if (target == null || source == null || origin == null) return null
return junctionPoints({
sourceSize: source.size, sourceSize: source.size,
targetOffset: target.sub(source.center()), targetOffset: target.sub(origin),
} })
return junctionPoints(inputs) })
const basePathElements = computed(() => {
const jp = currentJunctionPoints.value
if (jp == null) return undefined
return pathElements(jp)
}) })
const basePath = computed(() => { const basePath = computed(() => {
if (props.edge.source == null && props.edge.target == null) return undefined const pathElements = basePathElements.value
const jp = currentJunctionPoints.value if (!pathElements) return
if (jp == null) return undefined const { start, elements } = pathElements
const { start, elements } = pathElements(jp) const origin = sourceOriginPoint.value
const source_ = sourceRect.value if (origin == null) return undefined
if (source_ == null) return undefined return render(origin.add(start), elements)
return render(source_.center().add(start), elements)
}) })
const activePath = computed(() => { const activePath = computed(() => {
@ -449,11 +488,11 @@ const backwardEdgeArrowTransform = computed<string | undefined>(() => {
const points = currentJunctionPoints.value?.points const points = currentJunctionPoints.value?.points
if (points == null || points.length < 3) return if (points == null || points.length < 3) return
const target = targetPos.value const target = targetPos.value
const source = sourceRect.value const origin = sourceOriginPoint.value
if (target == null || source == null) return if (target == null || origin == null) return
if (target.y > source.pos.y - theme.edge.three_corner.backward_edge_arrow_threshold) return if (target.y > origin.y - theme.edge.three_corner.backward_edge_arrow_threshold) return
if (points[1] == null) return if (points[1] == null) return
return svgTranslate(source.center().add(points[1])) return svgTranslate(origin.add(points[1]))
}) })
const targetIsSelfArgument = computed(() => { const targetIsSelfArgument = computed(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -196,6 +196,11 @@ export class Rect {
height: this.size.y, 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) Rect.Zero = new Rect(Vec2.Zero, Vec2.Zero)