mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Address edge toolbar rendering glitches (#10839)
This commit is contained in:
parent
7f4ef31507
commit
e0c0ddee59
|
@ -7,7 +7,6 @@ import type {
|
||||||
ConnectStartEvent,
|
ConnectStartEvent,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type {
|
import type {
|
||||||
EdgeMouseEvent,
|
|
||||||
Connection,
|
Connection,
|
||||||
XYPosition,
|
XYPosition,
|
||||||
ViewportTransform,
|
ViewportTransform,
|
||||||
|
@ -31,6 +30,7 @@ import { isPresent } from '@/utils/typesUtils';
|
||||||
import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
import { GRID_SIZE } from '@/utils/nodeViewUtils';
|
||||||
import { CanvasKey } from '@/constants';
|
import { CanvasKey } from '@/constants';
|
||||||
import { onKeyDown, onKeyUp } from '@vueuse/core';
|
import { onKeyDown, onKeyUp } from '@vueuse/core';
|
||||||
|
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
|
|
||||||
|
@ -261,19 +261,7 @@ function onClickConnectionAdd(connection: Connection) {
|
||||||
emit('click:connection:add', connection);
|
emit('click:connection:add', connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const arrowHeadMarkerId = ref('custom-arrow-head');
|
||||||
* Connection hover
|
|
||||||
*/
|
|
||||||
|
|
||||||
const hoveredEdges = ref<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
function onMouseEnterEdge(event: EdgeMouseEvent) {
|
|
||||||
hoveredEdges.value[event.edge.id] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMouseLeaveEdge(event: EdgeMouseEvent) {
|
|
||||||
hoveredEdges.value[event.edge.id] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executions
|
* Executions
|
||||||
|
@ -511,8 +499,6 @@ provide(CanvasKey, {
|
||||||
:selection-key-code="selectionKeyCode"
|
:selection-key-code="selectionKeyCode"
|
||||||
:pan-activation-key-code="panningKeyCode"
|
:pan-activation-key-code="panningKeyCode"
|
||||||
data-test-id="canvas"
|
data-test-id="canvas"
|
||||||
@edge-mouse-enter="onMouseEnterEdge"
|
|
||||||
@edge-mouse-leave="onMouseLeaveEdge"
|
|
||||||
@connect-start="onConnectStart"
|
@connect-start="onConnectStart"
|
||||||
@connect="onConnect"
|
@connect="onConnect"
|
||||||
@connect-end="onConnectEnd"
|
@connect-end="onConnectEnd"
|
||||||
|
@ -543,8 +529,8 @@ provide(CanvasKey, {
|
||||||
<template #edge-canvas-edge="canvasEdgeProps">
|
<template #edge-canvas-edge="canvasEdgeProps">
|
||||||
<Edge
|
<Edge
|
||||||
v-bind="canvasEdgeProps"
|
v-bind="canvasEdgeProps"
|
||||||
|
:marker-end="`url(#${arrowHeadMarkerId})`"
|
||||||
:read-only="readOnly"
|
:read-only="readOnly"
|
||||||
:hovered="hoveredEdges[canvasEdgeProps.id]"
|
|
||||||
@add="onClickConnectionAdd"
|
@add="onClickConnectionAdd"
|
||||||
@delete="onDeleteConnection"
|
@delete="onDeleteConnection"
|
||||||
/>
|
/>
|
||||||
|
@ -554,6 +540,8 @@ provide(CanvasKey, {
|
||||||
<CanvasConnectionLine v-bind="connectionLineProps" />
|
<CanvasConnectionLine v-bind="connectionLineProps" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
|
||||||
|
|
||||||
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE" />
|
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE" />
|
||||||
|
|
||||||
<Transition name="minimap">
|
<Transition name="minimap">
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{ id: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
:id="id"
|
||||||
|
viewBox="-10 -10 20 20"
|
||||||
|
refX="0"
|
||||||
|
refY="0"
|
||||||
|
markerWidth="12.5"
|
||||||
|
markerHeight="12.5"
|
||||||
|
markerUnits="strokeWidth"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
points="-5,-4 0,0 -5,4 -5,-4"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke="context-stroke"
|
||||||
|
fill="context-stroke"
|
||||||
|
/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</template>
|
|
@ -1,10 +1,10 @@
|
||||||
import { fireEvent } from '@testing-library/vue';
|
|
||||||
import CanvasEdge, { type CanvasEdgeProps } from './CanvasEdge.vue';
|
import CanvasEdge, { type CanvasEdgeProps } from './CanvasEdge.vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { Position } from '@vue-flow/core';
|
import { Position } from '@vue-flow/core';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
const DEFAULT_PROPS = {
|
const DEFAULT_PROPS = {
|
||||||
sourceX: 0,
|
sourceX: 0,
|
||||||
|
@ -31,18 +31,21 @@ beforeEach(() => {
|
||||||
describe('CanvasEdge', () => {
|
describe('CanvasEdge', () => {
|
||||||
it('should emit delete event when toolbar delete is clicked', async () => {
|
it('should emit delete event when toolbar delete is clicked', async () => {
|
||||||
const { emitted, getByTestId } = renderComponent();
|
const { emitted, getByTestId } = renderComponent();
|
||||||
|
await userEvent.hover(getByTestId('edge-label-wrapper'));
|
||||||
const deleteButton = getByTestId('delete-connection-button');
|
const deleteButton = getByTestId('delete-connection-button');
|
||||||
|
|
||||||
await fireEvent.click(deleteButton);
|
await userEvent.click(deleteButton);
|
||||||
|
|
||||||
expect(emitted()).toHaveProperty('delete');
|
expect(emitted()).toHaveProperty('delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit add event when toolbar add is clicked', async () => {
|
it('should emit add event when toolbar add is clicked', async () => {
|
||||||
const { emitted, getByTestId } = renderComponent();
|
const { emitted, getByTestId } = renderComponent();
|
||||||
|
await userEvent.hover(getByTestId('edge-label-wrapper'));
|
||||||
|
|
||||||
const addButton = getByTestId('add-connection-button');
|
const addButton = getByTestId('add-connection-button');
|
||||||
|
|
||||||
await fireEvent.click(addButton);
|
await userEvent.click(addButton);
|
||||||
|
|
||||||
expect(emitted()).toHaveProperty('add');
|
expect(emitted()).toHaveProperty('add');
|
||||||
});
|
});
|
||||||
|
@ -54,6 +57,8 @@ describe('CanvasEdge', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await userEvent.hover(getByTestId('edge-label-wrapper'));
|
||||||
|
|
||||||
expect(() => getByTestId('add-connection-button')).toThrow();
|
expect(() => getByTestId('add-connection-button')).toThrow();
|
||||||
expect(() => getByTestId('delete-connection-button')).toThrow();
|
expect(() => getByTestId('delete-connection-button')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
import type { CanvasConnectionData } from '@/types';
|
import type { CanvasConnectionData } from '@/types';
|
||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
import type { Connection, EdgeProps } from '@vue-flow/core';
|
import type { Connection, EdgeProps } from '@vue-flow/core';
|
||||||
import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
|
import { useVueFlow, BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { computed, useCssModule } from 'vue';
|
import { computed, useCssModule, ref } from 'vue';
|
||||||
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
|
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
|
||||||
import { getCustomPath } from './utils/edgePath';
|
import { getCustomPath } from './utils/edgePath';
|
||||||
|
|
||||||
|
@ -21,6 +21,19 @@ export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
|
||||||
|
|
||||||
const props = defineProps<CanvasEdgeProps>();
|
const props = defineProps<CanvasEdgeProps>();
|
||||||
|
|
||||||
|
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 $style = useCssModule();
|
||||||
|
|
||||||
const connectionType = computed(() =>
|
const connectionType = computed(() =>
|
||||||
|
@ -29,7 +42,7 @@ const connectionType = computed(() =>
|
||||||
: NodeConnectionType.Main,
|
: NodeConnectionType.Main,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isFocused = computed(() => props.selected || props.hovered);
|
const renderToolbar = computed(() => (props.selected || isHovered.value) && !props.readOnly);
|
||||||
|
|
||||||
const status = computed(() => props.data.status);
|
const status = computed(() => props.data.status);
|
||||||
const statusColor = computed(() => {
|
const statusColor = computed(() => {
|
||||||
|
@ -49,22 +62,10 @@ const statusColor = computed(() => {
|
||||||
const edgeStyle = computed(() => ({
|
const edgeStyle = computed(() => ({
|
||||||
...props.style,
|
...props.style,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
stroke: statusColor.value,
|
stroke: isHovered.value ? 'var(--color-primary)' : statusColor.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const edgeLabel = computed(() => {
|
const edgeLabelStyle = computed(() => ({ color: statusColor.value }));
|
||||||
if (isFocused.value && !props.readOnly) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.label;
|
|
||||||
});
|
|
||||||
|
|
||||||
const edgeLabelStyle = computed(() => ({
|
|
||||||
fill: statusColor.value,
|
|
||||||
transform: 'translateY(calc(var(--spacing-xs) * -1))',
|
|
||||||
fontSize: 'var(--font-size-xs)',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const edgeToolbarStyle = computed(() => {
|
const edgeToolbarStyle = computed(() => {
|
||||||
const [, labelX, labelY] = path.value;
|
const [, labelX, labelY] = path.value;
|
||||||
|
@ -73,13 +74,6 @@ const edgeToolbarStyle = computed(() => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const edgeToolbarClasses = computed(() => ({
|
|
||||||
[$style.edgeToolbar]: true,
|
|
||||||
[$style.edgeToolbarVisible]: isFocused.value,
|
|
||||||
nodrag: true,
|
|
||||||
nopan: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const path = computed(() => getCustomPath(props));
|
const path = computed(() => getCustomPath(props));
|
||||||
|
|
||||||
const connection = computed<Connection>(() => ({
|
const connection = computed<Connection>(() => ({
|
||||||
|
@ -105,39 +99,47 @@ function onDelete() {
|
||||||
:style="edgeStyle"
|
:style="edgeStyle"
|
||||||
:path="path[0]"
|
:path="path[0]"
|
||||||
:marker-end="markerEnd"
|
:marker-end="markerEnd"
|
||||||
:label="edgeLabel"
|
:interaction-width="40"
|
||||||
:label-x="path[1]"
|
|
||||||
:label-y="path[2]"
|
|
||||||
:label-style="edgeLabelStyle"
|
|
||||||
:label-show-bg="false"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeLabelRenderer v-if="!readOnly">
|
<EdgeLabelRenderer>
|
||||||
<CanvasEdgeToolbar
|
<div
|
||||||
:type="connectionType"
|
data-test-id="edge-label-wrapper"
|
||||||
:class="edgeToolbarClasses"
|
|
||||||
:style="edgeToolbarStyle"
|
:style="edgeToolbarStyle"
|
||||||
@add="onAdd"
|
:class="$style.edgeLabelWrapper"
|
||||||
@delete="onDelete"
|
@mouseenter="isHovered = true"
|
||||||
/>
|
@mouseleave="isHovered = false"
|
||||||
|
>
|
||||||
|
<CanvasEdgeToolbar
|
||||||
|
v-if="renderToolbar"
|
||||||
|
:type="connectionType"
|
||||||
|
@add="onAdd"
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
<div v-else :style="edgeLabelStyle" :class="$style.edgeLabel">{{ label }}</div>
|
||||||
|
</div>
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.edge {
|
.edge {
|
||||||
transition: stroke 0.3s ease;
|
transition:
|
||||||
|
stroke 0.3s ease,
|
||||||
|
fill 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edgeToolbar {
|
.edgeLabelWrapper {
|
||||||
|
transform: translateY(calc(var(--spacing-xs) * -1));
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
}
|
||||||
|
|
||||||
&.edgeToolbarVisible {
|
.edgeLabel {
|
||||||
opacity: 1;
|
font-size: var(--font-size-xs);
|
||||||
}
|
background-color: hsla(
|
||||||
|
var(--color-canvas-background-h),
|
||||||
&:hover {
|
var(--color-canvas-background-s),
|
||||||
opacity: 1;
|
var(--color-canvas-background-l),
|
||||||
}
|
0.85
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -70,4 +70,8 @@ function onDelete() {
|
||||||
--button-background-color: var(--color-background-base);
|
--button-background-color: var(--color-background-base);
|
||||||
--button-hover-background-color: var(--color-background-light);
|
--button-hover-background-color: var(--color-background-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-edge-toolbar-button {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
Loading…
Reference in a new issue