mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 23:01:29 +03:00
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:
parent
56b289ae79
commit
322662ee9d
@ -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>
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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))
|
||||||
|
|
||||||
|
@ -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 } : {})
|
||||||
"
|
"
|
||||||
|
@ -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)
|
||||||
|
@ -95,4 +95,10 @@ const handler = {
|
|||||||
.bottom.left {
|
.bottom.left {
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right,
|
||||||
|
.bottom {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -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>
|
||||||
|
@ -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 }
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user