feat(editor): Improve node and edge bring-to-front mechanism on new canvas (#11793)

This commit is contained in:
Alex Grozav 2024-11-21 16:00:38 +02:00 committed by GitHub
parent 4dde287cde
commit b89ca9d482
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 200 additions and 76 deletions

View file

@ -154,6 +154,9 @@ export function createCanvasHandleProvide({
isReadOnly?: boolean;
isRequired?: boolean;
} = {}) {
const maxConnections = [NodeConnectionType.Main, NodeConnectionType.AiTool].includes(type)
? Infinity
: 1;
return {
[String(CanvasNodeHandleKey)]: {
label: ref(label),
@ -164,6 +167,7 @@ export function createCanvasHandleProvide({
isConnecting: ref(isConnecting),
isReadOnly: ref(isReadOnly),
isRequired: ref(isRequired),
maxConnections: ref(maxConnections),
runData: ref(runData),
} satisfies CanvasNodeHandleInjectionData,
};

View file

@ -29,10 +29,11 @@ import type { PinDataSource } from '@/composables/usePinnedData';
import { isPresent } from '@/utils/typesUtils';
import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { CanvasKey } from '@/constants';
import { onKeyDown, onKeyUp } from '@vueuse/core';
import { onKeyDown, onKeyUp, useThrottleFn } from '@vueuse/core';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import CanvasBackgroundStripedPattern from './elements/CanvasBackgroundStripedPattern.vue';
import { useCanvasTraversal } from '@/composables/useCanvasTraversal';
import { NodeConnectionType } from 'n8n-workflow';
const $style = useCssModule();
@ -115,6 +116,11 @@ const {
onPaneReady,
findNode,
viewport,
onEdgeMouseLeave,
onEdgeMouseEnter,
onEdgeMouseMove,
onNodeMouseEnter,
onNodeMouseLeave,
} = vueFlow;
const {
getIncomingNodes,
@ -297,7 +303,7 @@ function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>)
}
/**
* Connections
* Connections / Edges
*/
const connectionCreated = ref(false);
@ -339,6 +345,55 @@ function onClickConnectionAdd(connection: Connection) {
const arrowHeadMarkerId = ref('custom-arrow-head');
/**
* Edge and Nodes Hovering
*/
const edgesHoveredById = ref<Record<string, boolean>>({});
const edgesBringToFrontById = ref<Record<string, boolean>>({});
const nodesHoveredById = ref<Record<string, boolean>>({});
onEdgeMouseEnter(({ edge }) => {
edgesBringToFrontById.value = { [edge.id]: true };
edgesHoveredById.value = { [edge.id]: true };
});
onEdgeMouseMove(
useThrottleFn(({ edge, event }) => {
const type = edge.data.source.type;
if (type !== NodeConnectionType.AiTool) {
return;
}
if (!edge.data.maxConnections || edge.data.maxConnections > 1) {
const projectedPosition = getProjectedPosition(event);
const yDiff = projectedPosition.y - edge.targetY;
if (yDiff < 4 * GRID_SIZE) {
edgesBringToFrontById.value = { [edge.id]: false };
} else {
edgesBringToFrontById.value = { [edge.id]: true };
}
}
}, 100),
);
onEdgeMouseLeave(({ edge }) => {
edgesBringToFrontById.value = { [edge.id]: false };
edgesHoveredById.value = { [edge.id]: false };
});
onNodeMouseEnter(({ node }) => {
nodesHoveredById.value = { [node.id]: true };
});
onNodeMouseLeave(({ node }) => {
nodesHoveredById.value = { [node.id]: false };
});
function onUpdateEdgeHovered(id: string, hovered: boolean) {
edgesHoveredById.value[id] = hovered;
}
/**
* Executions
*/
@ -375,7 +430,7 @@ const defaultZoom = 1;
const zoom = ref(defaultZoom);
const isPaneMoving = ref(false);
function getProjectedPosition(event?: MouseEvent) {
function getProjectedPosition(event?: Pick<MouseEvent, 'clientX' | 'clientY'>) {
const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 };
const offsetX = event?.clientX ?? 0;
const offsetY = event?.clientY ?? 0;
@ -590,11 +645,13 @@ provide(CanvasKey, {
@move-end="onPaneMoveEnd"
@node-drag-stop="onNodeDragStop"
>
<template #node-canvas-node="canvasNodeProps">
<template #node-canvas-node="nodeProps">
<Node
v-bind="canvasNodeProps"
v-bind="nodeProps"
:read-only="readOnly"
:event-bus="eventBus"
:hovered="nodesHoveredById[nodeProps.id]"
:bring-to-front="nodesHoveredById[nodeProps.id]"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"
@ -607,13 +664,16 @@ provide(CanvasKey, {
/>
</template>
<template #edge-canvas-edge="canvasEdgeProps">
<template #edge-canvas-edge="edgeProps">
<Edge
v-bind="canvasEdgeProps"
v-bind="edgeProps"
:marker-end="`url(#${arrowHeadMarkerId})`"
:read-only="readOnly"
:hovered="edgesHoveredById[edgeProps.id]"
:bring-to-front="edgesBringToFrontById[edgeProps.id]"
@add="onClickConnectionAdd"
@delete="onDeleteConnection"
@update:hovered="onUpdateEdgeHovered(edgeProps.id, $event)"
/>
</template>

View file

@ -30,7 +30,11 @@ beforeEach(() => {
describe('CanvasEdge', () => {
it('should emit delete event when toolbar delete is clicked', async () => {
const { emitted, getByTestId } = renderComponent();
const { emitted, getByTestId } = renderComponent({
props: {
hovered: true,
},
});
await userEvent.hover(getByTestId('edge-label-wrapper'));
const deleteButton = getByTestId('delete-connection-button');
@ -40,7 +44,11 @@ describe('CanvasEdge', () => {
});
it('should emit add event when toolbar add is clicked', async () => {
const { emitted, getByTestId } = renderComponent();
const { emitted, getByTestId } = renderComponent({
props: {
hovered: true,
},
});
await userEvent.hover(getByTestId('edge-label-wrapper'));
const addButton = getByTestId('add-connection-button');

View file

@ -3,40 +3,28 @@
import type { CanvasConnectionData } from '@/types';
import { isValidNodeConnectionType } from '@/utils/typeGuards';
import type { Connection, EdgeProps } from '@vue-flow/core';
import { useVueFlow, BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow';
import { computed, useCssModule, ref, toRef } from 'vue';
import { computed, useCssModule, toRef } from 'vue';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { getCustomPath } from './utils/edgePath';
const emit = defineEmits<{
add: [connection: Connection];
delete: [connection: Connection];
'update:hovered': [hovered: boolean];
}>();
export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
readOnly?: boolean;
hovered?: boolean;
bringToFront?: boolean; // Determines if entire edges layer should be brought to front
};
const props = defineProps<CanvasEdgeProps>();
const data = toRef(props, 'data');
const { onEdgeMouseEnter, onEdgeMouseLeave } = useVueFlow();
const isHovered = ref(false);
onEdgeMouseEnter(({ edge }) => {
if (edge.id !== props.id) return;
isHovered.value = true;
});
onEdgeMouseLeave(({ edge }) => {
if (edge.id !== props.id) return;
isHovered.value = false;
});
const $style = useCssModule();
const connectionType = computed(() =>
@ -45,7 +33,7 @@ const connectionType = computed(() =>
: NodeConnectionType.Main,
);
const renderToolbar = computed(() => isHovered.value && !props.readOnly);
const renderToolbar = computed(() => props.hovered && !props.readOnly);
const isMainConnection = computed(() => data.value.source.type === NodeConnectionType.Main);
@ -71,12 +59,13 @@ const edgeStyle = computed(() => ({
...props.style,
...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }),
strokeWidth: 2,
stroke: isHovered.value ? 'var(--color-primary)' : edgeColor.value,
stroke: props.hovered ? 'var(--color-primary)' : edgeColor.value,
}));
const edgeClasses = computed(() => ({
[$style.edge]: true,
hovered: isHovered.value,
hovered: props.hovered,
'bring-to-front': props.bringToFront,
}));
const edgeLabelStyle = computed(() => ({
@ -87,7 +76,7 @@ const edgeToolbarStyle = computed(() => {
const [, labelX, labelY] = path.value;
return {
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
...(isHovered.value ? { zIndex: 1 } : {}),
...(props.hovered ? { zIndex: 1 } : {}),
};
});
@ -117,6 +106,14 @@ function onAdd() {
function onDelete() {
emit('delete', connection.value);
}
function onEdgeLabelMouseEnter() {
emit('update:hovered', true);
}
function onEdgeLabelMouseLeave() {
emit('update:hovered', false);
}
</script>
<template>
@ -137,8 +134,8 @@ function onDelete() {
:data-edge-status="status"
:style="edgeToolbarStyle"
:class="edgeToolbarClasses"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@mouseenter="onEdgeLabelMouseEnter"
@mouseleave="onEdgeLabelMouseLeave"
>
<CanvasEdgeToolbar
v-if="renderToolbar"

View file

@ -126,6 +126,7 @@ const mode = toRef(props, 'mode');
const type = toRef(props, 'type');
const index = toRef(props, 'index');
const isRequired = toRef(props, 'required');
const maxConnections = toRef(props, 'maxConnections');
provide(CanvasNodeHandleKey, {
label,
@ -137,6 +138,7 @@ provide(CanvasNodeHandleKey, {
isConnected,
isConnecting,
isReadOnly,
maxConnections,
});
</script>
@ -155,6 +157,7 @@ provide(CanvasNodeHandleKey, {
<RenderType
:class="renderTypeClasses"
:is-connected="isConnected"
:max-connections="maxConnections"
:style="offset"
:label="label"
@add="onAdd"

View file

@ -1,7 +1,6 @@
<script lang="ts" setup>
import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue';
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { NodeConnectionType } from 'n8n-workflow';
import { computed, ref, useCssModule } from 'vue';
const emit = defineEmits<{
@ -10,7 +9,7 @@ const emit = defineEmits<{
const $style = useCssModule();
const { label, isConnected, isConnecting, isRequired, type } = useCanvasNodeHandle();
const { label, isConnected, isConnecting, isRequired, maxConnections } = useCanvasNodeHandle();
const handleClasses = 'target';
@ -20,14 +19,12 @@ const classes = computed(() => ({
[$style.required]: isRequired.value,
}));
const supportsMultipleConnections = computed(() => type.value === NodeConnectionType.AiTool);
const isHandlePlusAvailable = computed(
() => !isConnected.value || supportsMultipleConnections.value,
() => !isConnected.value || !maxConnections.value || maxConnections.value > 1,
);
const isHandlePlusVisible = computed(
() => !isConnecting.value || isHovered.value || supportsMultipleConnections.value,
() => !isConnecting.value || isHovered.value || !maxConnections.value || maxConnections.value > 1,
);
const isHovered = ref(false);

View file

@ -156,18 +156,18 @@ function onClick(event: MouseEvent) {
stroke: var(--color-success);
}
}
}
.plus {
&:hover {
cursor: pointer;
.plus {
&:hover {
cursor: pointer;
path {
fill: var(--color-primary);
}
path {
fill: var(--color-primary);
}
rect {
stroke: var(--color-primary);
rect {
stroke: var(--color-primary);
}
}
}
}

View file

@ -1,5 +1,14 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue';
import {
computed,
onBeforeUnmount,
onMounted,
provide,
ref,
toRef,
useCssModule,
watch,
} from 'vue';
import type {
CanvasConnectionPort,
CanvasElementPortWithRenderData,
@ -26,6 +35,8 @@ import { createEventBus } from 'n8n-design-system';
type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean;
eventBus?: EventBus<CanvasEventBusEvents>;
hovered?: boolean;
bringToFront?: boolean; // Determines if entire nodes layer should be brought to front
};
const emit = defineEmits<{
@ -40,6 +51,8 @@ const emit = defineEmits<{
move: [id: string, position: XYPosition];
}>();
const style = useCssModule();
const props = defineProps<Props>();
const nodeTypesStore = useNodeTypesStore();
@ -63,6 +76,14 @@ const nodeTypeDescription = computed(() => {
return nodeTypesStore.getNodeType(props.data.type, props.data.typeVersion);
});
const classes = computed(() => ({
[style.canvasNode]: true,
[style.showToolbar]: showToolbar.value,
hovered: props.hovered,
selected: props.selected,
'bring-to-front': props.bringToFront,
}));
/**
* Event bus
*/
@ -256,11 +277,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div
:class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]"
data-test-id="canvas-node"
:data-node-type="data.type"
>
<div :class="classes" data-test-id="canvas-node" :data-node-type="data.type">
<template
v-for="source in mappedOutputs"
:key="`${source.handleId}(${source.index + 1}/${mappedOutputs.length})`"

View file

@ -514,30 +514,40 @@ export function useCanvasMapping({
});
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
const fromNode = nodes.value.find((node) => node.name === connection.data?.fromNodeName);
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const runDataTotal =
nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index]?.total ?? 0;
let status: CanvasConnectionData['status'];
if (fromNode) {
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const runDataTotal =
nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index]?.total ?? 0;
if (nodeExecutionRunningById.value[fromNode.id]) {
status = 'running';
} else if (
nodePinnedDataById.value[fromNode.id] &&
nodeExecutionRunDataById.value[fromNode.id]
) {
status = 'pinned';
} else if (nodeHasIssuesById.value[fromNode.id]) {
status = 'error';
} else if (runDataTotal > 0) {
status = 'success';
}
if (nodeExecutionRunningById.value[connection.source]) {
status = 'running';
} else if (
nodePinnedDataById.value[connection.source] &&
nodeExecutionRunDataById.value[connection.source]
) {
status = 'pinned';
} else if (nodeHasIssuesById.value[connection.source]) {
status = 'error';
} else if (runDataTotal > 0) {
status = 'success';
}
const maxConnections = [
...nodeInputsById.value[connection.source],
...nodeInputsById.value[connection.target],
]
.filter((port) => port.type === type)
.reduce<number | undefined>((acc, port) => {
if (port.maxConnections === undefined) {
return acc;
}
return Math.min(acc ?? Infinity, port.maxConnections);
}, undefined);
return {
...(connection.data as CanvasConnectionData),
...(maxConnections ? { maxConnections } : {}),
status,
};
}

View file

@ -16,6 +16,7 @@ export function useCanvasNodeHandle() {
const isConnecting = computed(() => handle?.isConnecting.value ?? false);
const isReadOnly = computed(() => handle?.isReadOnly.value);
const isRequired = computed(() => handle?.isRequired.value);
const maxConnections = computed(() => handle?.maxConnections.value);
const type = computed(() => handle?.type.value ?? NodeConnectionType.Main);
const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input);
const index = computed(() => handle?.index.value ?? 0);
@ -27,6 +28,7 @@ export function useCanvasNodeHandle() {
isConnecting,
isReadOnly,
isRequired,
maxConnections,
type,
mode,
index,

View file

@ -77,6 +77,10 @@
}
}
.vue-flow__nodes:has(.bring-to-front) {
z-index: 2 !important;
}
/**
* Selection
*/
@ -92,8 +96,7 @@
* Edges
*/
.vue-flow__edges:has(.selected),
.vue-flow__edges:has(.hovered),
.vue-flow__edges:has(.bring-to-front),
.vue-flow__edge-label.selected {
z-index: 1 !important;
}

View file

@ -118,6 +118,7 @@ export interface CanvasConnectionData {
target: CanvasConnectionPort;
fromNodeName?: string;
status?: 'success' | 'error' | 'pinned' | 'running';
maxConnections?: number;
}
export type CanvasConnection = DefaultEdge<CanvasConnectionData>;
@ -173,6 +174,7 @@ export interface CanvasNodeHandleInjectionData {
isConnected: ComputedRef<boolean | undefined>;
isConnecting: Ref<boolean | undefined>;
isReadOnly: Ref<boolean | undefined>;
maxConnections: Ref<number | undefined>;
runData: Ref<ExecutionOutputMapData | undefined>;
}

View file

@ -807,7 +807,12 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
expect(result).toEqual([
{ type: NodeConnectionType.Main, index: 0, label: undefined },
{ type: NodeConnectionType.AiTool, index: 0, label: undefined },
{
type: NodeConnectionType.AiTool,
index: 0,
label: undefined,
maxConnections: undefined,
},
]);
});
@ -820,7 +825,13 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
expect(result).toEqual([
{ type: NodeConnectionType.Main, index: 0, label: 'Main Input' },
{ type: NodeConnectionType.AiTool, index: 0, label: 'AI Tool', required: true },
{
type: NodeConnectionType.AiTool,
index: 0,
label: 'AI Tool',
required: true,
maxConnections: undefined,
},
]);
});
@ -834,7 +845,12 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
expect(result).toEqual([
{ type: NodeConnectionType.Main, index: 0, label: undefined },
{ type: NodeConnectionType.AiTool, index: 0, label: 'AI Tool' },
{
type: NodeConnectionType.AiTool,
index: 0,
label: 'AI Tool',
maxConnections: undefined,
},
{ type: NodeConnectionType.Main, index: 1, label: undefined },
]);
});
@ -874,7 +890,12 @@ describe('mapLegacyEndpointsToCanvasConnectionPort', () => {
expect(result).toEqual([
{ type: NodeConnectionType.Main, index: 0, label: 'Main Input', required: true },
{ type: NodeConnectionType.AiTool, index: 0, label: 'Optional Tool' },
{
type: NodeConnectionType.AiTool,
index: 0,
label: 'Optional Tool',
maxConnections: undefined,
},
]);
});