feat(editor): Overhaul handle rendering to allow dragging of plus button (no-changelog) (#10512)

This commit is contained in:
Alex Grozav 2024-08-26 10:32:39 +03:00 committed by GitHub
parent cd0c6d9b55
commit 351d8413e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 623 additions and 170 deletions

View file

@ -110,19 +110,22 @@ export function createCanvasHandleProvide({
label = 'Handle', label = 'Handle',
mode = CanvasConnectionMode.Input, mode = CanvasConnectionMode.Input,
type = NodeConnectionType.Main, type = NodeConnectionType.Main,
connected = false, isConnected = false,
isConnecting = false,
}: { }: {
label?: string; label?: string;
mode?: CanvasConnectionMode; mode?: CanvasConnectionMode;
type?: NodeConnectionType; type?: NodeConnectionType;
connected?: boolean; isConnected?: boolean;
isConnecting?: boolean;
} = {}) { } = {}) {
return { return {
[`${CanvasNodeHandleKey}`]: { [`${CanvasNodeHandleKey}`]: {
label: ref(label), label: ref(label),
mode: ref(mode), mode: ref(mode),
type: ref(type), type: ref(type),
connected: ref(connected), isConnected: ref(isConnected),
isConnecting: ref(isConnecting),
} satisfies CanvasNodeHandleInjectionData, } satisfies CanvasNodeHandleInjectionData,
}; };
} }

View file

@ -12,7 +12,7 @@ import { Background } from '@vue-flow/background';
import { MiniMap } from '@vue-flow/minimap'; import { MiniMap } from '@vue-flow/minimap';
import Node from './elements/nodes/CanvasNode.vue'; import Node from './elements/nodes/CanvasNode.vue';
import Edge from './elements/edges/CanvasEdge.vue'; import Edge from './elements/edges/CanvasEdge.vue';
import { computed, onMounted, onUnmounted, ref, useCssModule, watch } from 'vue'; import { computed, onMounted, onUnmounted, provide, ref, useCssModule, watch } from 'vue';
import type { EventBus } from 'n8n-design-system'; import type { EventBus } from 'n8n-design-system';
import { createEventBus } from 'n8n-design-system'; import { createEventBus } from 'n8n-design-system';
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu'; import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
@ -22,6 +22,7 @@ import type { NodeCreatorOpenSource } from '@/Interface';
import type { PinDataSource } from '@/composables/usePinnedData'; import type { PinDataSource } from '@/composables/usePinnedData';
import { isPresent } from '@/utils/typesUtils'; import { isPresent } from '@/utils/typesUtils';
import { GRID_SIZE } from '@/utils/nodeViewUtils'; import { GRID_SIZE } from '@/utils/nodeViewUtils';
import { CanvasKey } from '@/constants';
const $style = useCssModule(); const $style = useCssModule();
@ -48,8 +49,8 @@ const emit = defineEmits<{
'delete:connection': [connection: Connection]; 'delete:connection': [connection: Connection];
'create:connection:start': [handle: ConnectStartEvent]; 'create:connection:start': [handle: ConnectStartEvent];
'create:connection': [connection: Connection]; 'create:connection': [connection: Connection];
'create:connection:end': [connection: Connection]; 'create:connection:end': [connection: Connection, event?: MouseEvent];
'create:connection:cancelled': [handle: ConnectStartEvent]; 'create:connection:cancelled': [handle: ConnectStartEvent, event?: MouseEvent];
'click:connection:add': [connection: Connection]; 'click:connection:add': [connection: Connection];
'click:pane': [position: XYPosition]; 'click:pane': [position: XYPosition];
'run:workflow': []; 'run:workflow': [];
@ -184,35 +185,32 @@ function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>)
*/ */
const connectionCreated = ref(false); const connectionCreated = ref(false);
const connectionEventData = ref<ConnectStartEvent | Connection>(); const connectingHandle = ref<ConnectStartEvent>();
const connectedHandle = ref<Connection>();
const isConnection = (data: ConnectStartEvent | Connection | undefined): data is Connection =>
!!data && connectionCreated.value;
const isConnectionCancelled = (
data: ConnectStartEvent | Connection | undefined,
): data is ConnectStartEvent => !!data && !connectionCreated.value;
function onConnectStart(handle: ConnectStartEvent) { function onConnectStart(handle: ConnectStartEvent) {
emit('create:connection:start', handle); emit('create:connection:start', handle);
connectionEventData.value = handle; connectingHandle.value = handle;
connectionCreated.value = false; connectionCreated.value = false;
} }
function onConnect(connection: Connection) { function onConnect(connection: Connection) {
emit('create:connection', connection); emit('create:connection', connection);
connectionEventData.value = connection; connectedHandle.value = connection;
connectionCreated.value = true; connectionCreated.value = true;
} }
function onConnectEnd() { function onConnectEnd(event?: MouseEvent) {
if (isConnection(connectionEventData.value)) { if (connectedHandle.value) {
emit('create:connection:end', connectionEventData.value); emit('create:connection:end', connectedHandle.value, event);
} else if (isConnectionCancelled(connectionEventData.value)) { } else if (connectingHandle.value) {
emit('create:connection:cancelled', connectionEventData.value); emit('create:connection:cancelled', connectingHandle.value, event);
} }
connectedHandle.value = undefined;
connectingHandle.value = undefined;
} }
function onDeleteConnection(connection: Connection) { function onDeleteConnection(connection: Connection) {
@ -393,6 +391,14 @@ onPaneReady(async () => {
watch(() => props.readOnly, setReadonly, { watch(() => props.readOnly, setReadonly, {
immediate: true, immediate: true,
}); });
/**
* Provide
*/
provide(CanvasKey, {
connectingHandle,
});
</script> </script>
<template> <template>

View file

@ -7,14 +7,17 @@ import { CanvasConnectionMode } from '@/types';
import type { ValidConnectionFunc } from '@vue-flow/core'; import type { ValidConnectionFunc } from '@vue-flow/core';
import { Handle } from '@vue-flow/core'; import { Handle } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue'; import CanvasHandleMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainOutput.vue';
import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue'; import CanvasHandleNonMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainInput.vue';
import CanvasHandleNonMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue';
import { CanvasNodeHandleKey } from '@/constants'; import { CanvasNodeHandleKey } from '@/constants';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2'; import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
const props = defineProps<{ const props = defineProps<{
mode: CanvasConnectionMode; mode: CanvasConnectionMode;
connected?: boolean; isConnected?: boolean;
isConnecting?: boolean;
label?: string; label?: string;
type: CanvasConnectionPort['type']; type: CanvasConnectionPort['type'];
index: CanvasConnectionPort['index']; index: CanvasConnectionPort['index'];
@ -59,13 +62,6 @@ const handleClasses = computed(() => [style.handle, style[props.type], style[pro
* Render additional elements * Render additional elements
*/ */
const hasRenderType = computed(() => {
return (
(props.type === NodeConnectionType.Main && props.mode === CanvasConnectionMode.Output) ||
props.type !== NodeConnectionType.Main
);
});
const renderTypeClasses = computed(() => [style.renderType, style[props.position]]); const renderTypeClasses = computed(() => [style.renderType, style[props.position]]);
const RenderType = () => { const RenderType = () => {
@ -74,9 +70,13 @@ const RenderType = () => {
if (props.mode === CanvasConnectionMode.Output) { if (props.mode === CanvasConnectionMode.Output) {
if (props.type === NodeConnectionType.Main) { if (props.type === NodeConnectionType.Main) {
Component = CanvasHandleMainOutput; Component = CanvasHandleMainOutput;
} else {
Component = CanvasHandleNonMainOutput;
} }
} else { } else {
if (props.type !== NodeConnectionType.Main) { if (props.type === NodeConnectionType.Main) {
Component = CanvasHandleMainInput;
} else {
Component = CanvasHandleNonMainInput; Component = CanvasHandleNonMainInput;
} }
} }
@ -97,7 +97,8 @@ function onAdd() {
*/ */
const label = toRef(props, 'label'); const label = toRef(props, 'label');
const connected = toRef(props, 'connected'); const isConnected = toRef(props, 'isConnected');
const isConnecting = toRef(props, 'isConnecting');
const mode = toRef(props, 'mode'); const mode = toRef(props, 'mode');
const type = toRef(props, 'type'); const type = toRef(props, 'type');
@ -105,7 +106,8 @@ provide(CanvasNodeHandleKey, {
label, label,
mode, mode,
type, type,
connected, isConnected,
isConnecting,
}); });
</script> </script>
@ -120,78 +122,58 @@ provide(CanvasNodeHandleKey, {
:connectable-start="isConnectableStart" :connectable-start="isConnectableStart"
:connectable-end="isConnectableEnd" :connectable-end="isConnectableEnd"
:is-valid-connection="isValidConnection" :is-valid-connection="isValidConnection"
/> >
<RenderType <RenderType
v-if="hasRenderType" :class="renderTypeClasses"
:class="renderTypeClasses" :is-connected="isConnected"
:connected="connected" :style="offset"
:style="offset" :label="label"
:label="label" @add="onAdd"
@add="onAdd" />
/> </Handle>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.handle { .handle {
width: 16px; --handle--indicator--width: 16px;
height: 16px; --handle--indicator--height: 16px;
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border: 0; border: 0;
z-index: 1; z-index: 1;
background: var(--color-foreground-xdark); background: transparent;
border-radius: 0;
&:hover {
background: var(--color-primary);
}
&.inputs { &.inputs {
&.main { &.main {
width: 8px; --handle--indicator--width: 8px;
border-radius: 0;
}
&:not(.main) {
width: 14px;
height: 14px;
transform-origin: 0 0;
transform: rotate(45deg) translate(2px, 2px);
border-radius: 0;
background: hsl(
var(--node-type-supplemental-color-h) var(--node-type-supplemental-color-s)
var(--node-type-supplemental-color-l)
);
&:hover {
background: var(--color-primary);
}
} }
} }
} }
.renderType { .renderType {
position: absolute;
z-index: 0;
&.top { &.top {
top: 0; margin-bottom: calc(-1 * var(--handle--indicator--height));
transform: translate(-50%, -50%); transform: translate(0%, -50%);
} }
&.right { &.right {
right: 0; margin-left: calc(-1 * var(--handle--indicator--width));
transform: translate(100%, -50%); transform: translate(50%, 0%);
} }
&.left { &.left {
left: 0; margin-right: calc(-1 * var(--handle--indicator--width));
transform: translate(-50%, -50%); transform: translate(-50%, 0%);
} }
&.bottom { &.bottom {
bottom: 0; margin-top: calc(-1 * var(--handle--indicator--height));
transform: translate(-50%, 50%); transform: translate(0%, 50%);
} }
} }
</style> </style>

View file

@ -0,0 +1,21 @@
import CanvasHandleMainInput from '@/components/canvas/elements/handles/render-types/CanvasHandleMainInput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasHandleProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasHandleMainInput);
describe('CanvasHandleMainInput', () => {
it('should render correctly', async () => {
const label = 'Test Label';
const { container, getByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label }),
},
},
});
expect(container.querySelector('.canvas-node-handle-main-input')).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
const { label } = useCanvasNodeHandle();
const handleClasses = 'target';
</script>
<template>
<div :class="['canvas-node-handle-main-input', $style.handle]">
<div :class="[$style.label]">{{ label }}</div>
<CanvasHandleRectangle :handle-classes="handleClasses" />
</div>
</template>
<style lang="scss" module>
.handle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.label {
position: absolute;
top: 20px;
left: 50%;
transform: translate(-50%, 0);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-background-light);
z-index: 1;
text-align: center;
}
</style>

View file

@ -1,12 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue'; import { computed, ref } from 'vue';
const emit = defineEmits<{ const emit = defineEmits<{
add: []; add: [];
}>(); }>();
const { label, connected } = useCanvasNodeHandle(); const { label, isConnected, isConnecting } = useCanvasNodeHandle();
const handleClasses = 'source';
const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value);
const isHovered = ref(false);
function onMouseEnter() {
isHovered.value = true;
}
function onMouseLeave() {
isHovered.value = false;
}
function onClickAdd() { function onClickAdd() {
emit('add'); emit('add');
@ -14,16 +27,27 @@ function onClickAdd() {
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-main-output', $style.handle]"> <div :class="['canvas-node-handle-main-output', $style.handle]">
<div :class="$style.label">{{ label }}</div> <div :class="[$style.label]">{{ label }}</div>
<CanvasHandlePlus v-if="!connected" @click:plus="onClickAdd" /> <CanvasHandleDot :handle-classes="handleClasses" />
<Transition name="canvas-node-handle-main-output">
<CanvasHandlePlus
v-if="!isConnected"
v-show="isHandlePlusVisible"
:handle-classes="handleClasses"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click:plus="onClickAdd"
/>
</Transition>
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.handle { .handle {
:global(.vue-flow__handle:not(.connectionindicator)) + & { display: flex;
display: none; flex-direction: row;
} align-items: center;
justify-content: center;
} }
.label { .label {
@ -37,3 +61,19 @@ function onClickAdd() {
z-index: 1; z-index: 1;
} }
</style> </style>
<style lang="scss">
.canvas-node-handle-main-output-enter-active,
.canvas-node-handle-main-output-leave-active {
transform-origin: 0 center;
transition-property: transform, opacity;
transition-duration: 0.2s;
transition-timing-function: ease;
}
.canvas-node-handle-main-output-enter-from,
.canvas-node-handle-main-output-leave-to {
transform: scale(0);
opacity: 0;
}
</style>

View file

@ -15,7 +15,7 @@ describe('CanvasHandleNonMainInput', () => {
}, },
}); });
expect(container.querySelector('.canvas-node-handle-non-main')).toBeInTheDocument(); expect(container.querySelector('.canvas-node-handle-non-main-input')).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument(); expect(getByText(label)).toBeInTheDocument();
}); });
}); });

View file

@ -2,48 +2,89 @@
import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue'; import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue';
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { computed } from 'vue'; import { computed, ref } from 'vue';
const emit = defineEmits<{ const emit = defineEmits<{
add: []; add: [];
}>(); }>();
const { label, connected, type } = useCanvasNodeHandle(); const { label, isConnected, isConnecting, type } = useCanvasNodeHandle();
const isAddButtonVisible = computed( const handleClasses = 'target';
() => !connected.value || type.value === NodeConnectionType.AiTool,
const supportsMultipleConnections = computed(() => type.value === NodeConnectionType.AiTool);
const isHandlePlusAvailable = computed(
() => !isConnected.value || supportsMultipleConnections.value,
); );
const isHandlePlusVisible = computed(
() => !isConnecting.value || isHovered.value || supportsMultipleConnections.value,
);
const isHovered = ref(false);
function onMouseEnter() {
isHovered.value = true;
}
function onMouseLeave() {
isHovered.value = false;
}
function onClickAdd() { function onClickAdd() {
emit('add'); emit('add');
} }
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-non-main', $style.handle]"> <div :class="['canvas-node-handle-non-main-input', $style.handle]">
<div :class="$style.label">{{ label }}</div> <div :class="[$style.label]">{{ label }}</div>
<CanvasHandlePlus v-if="isAddButtonVisible" :class="$style.plus" @click="onClickAdd" /> <CanvasHandleDiamond :handle-classes="handleClasses" />
<Transition name="canvas-node-handle-non-main-input">
<CanvasHandlePlus
v-if="isHandlePlusAvailable"
v-show="isHandlePlusVisible"
:handle-classes="handleClasses"
position="bottom"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click:plus="onClickAdd"
/>
</Transition>
</div> </div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.handle { .handle {
:global(.vue-flow__handle:not(.connectionindicator)) + & { display: flex;
display: none; flex-direction: column;
} align-items: center;
justify-content: center;
} }
.label { .label {
position: absolute; position: absolute;
top: 18px; top: 20px;
left: 50%; left: 50%;
transform: translate(-50%, 0); transform: translate(-50%, 0);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark); color: var(--color-foreground-xdark);
background: var(--color-background-light); background: var(--color-background-light);
z-index: 1; z-index: 1;
} text-align: center;
}
.plus { </style>
transform: rotate(90deg) translateX(50%);
<style lang="scss">
.canvas-node-handle-non-main-input-enter-active,
.canvas-node-handle-non-main-input-leave-active {
transform-origin: center 0;
transition-property: transform, opacity;
transition-duration: 0.2s;
transition-timing-function: ease;
}
.canvas-node-handle-non-main-input-enter-from,
.canvas-node-handle-non-main-input-leave-to {
transform: scale(0);
opacity: 0;
} }
</style> </style>

View file

@ -0,0 +1,21 @@
import CanvasHandleNonMainOutput from '@/components/canvas/elements/handles/render-types/CanvasHandleNonMainOutput.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasHandleProvide } from '@/__tests__/data';
const renderComponent = createComponentRenderer(CanvasHandleNonMainOutput);
describe('CanvasHandleNonMainOutput', () => {
it('should render correctly', async () => {
const label = 'Test Label';
const { container, getByText } = renderComponent({
global: {
provide: {
...createCanvasHandleProvide({ label }),
},
},
});
expect(container.querySelector('.canvas-node-handle-non-main-output')).toBeInTheDocument();
expect(getByText(label)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,38 @@
<script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
const { label } = useCanvasNodeHandle();
const handleClasses = 'source';
</script>
<template>
<div :class="['canvas-node-handle-non-main-output', $style.handle]">
<div :class="[$style.label]">{{ label }}</div>
<CanvasHandleDiamond :handle-classes="handleClasses" />
</div>
</template>
<style lang="scss" module>
.handle {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.label {
position: absolute;
top: -20px;
left: -50%;
transform: translate(0%, 0);
font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark);
background: var(--color-background-light);
z-index: 0;
}
:global(.vue-flow__handle:not(.connectionindicator)) .plus {
display: none;
position: absolute;
}
</style>

View file

@ -0,0 +1,21 @@
import CanvasHandlePlus from './CanvasHandlePlus.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasHandlePlus, {});
describe('CanvasHandleDiamond', () => {
it('should render with default props', () => {
const { html } = renderComponent();
expect(html()).toMatchSnapshot();
});
it('should apply `handleClasses` prop correctly', () => {
const customClass = 'custom-handle-class';
const wrapper = renderComponent({
props: { handleClasses: customClass },
});
expect(wrapper.container.querySelector(`.${customClass}`)).toBeTruthy();
});
});

View file

@ -0,0 +1,27 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
handleClasses?: string;
}>(),
{
handleClasses: undefined,
},
);
</script>
<template>
<div :class="[$style.diamond, handleClasses]" />
</template>
<style lang="scss" module>
.diamond {
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
background: var(--node-type-supplemental-color);
transform: rotate(45deg) scale(0.8);
&:hover {
background: var(--color-primary);
}
}
</style>

View file

@ -0,0 +1,21 @@
import CanvasHandleDot from './CanvasHandleDot.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasHandleDot, {});
describe('CanvasHandleDot', () => {
it('should render with default props', () => {
const { html } = renderComponent();
expect(html()).toMatchSnapshot();
});
it('should apply `handleClasses` prop correctly', () => {
const customClass = 'custom-handle-class';
const wrapper = renderComponent({
props: { handleClasses: customClass },
});
expect(wrapper.container.querySelector(`.${customClass}`)).toBeTruthy();
});
});

View file

@ -0,0 +1,27 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
handleClasses?: string;
}>(),
{
handleClasses: undefined,
},
);
</script>
<template>
<div :class="[$style.dot, handleClasses]" />
</template>
<style lang="scss" module>
.dot {
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
border-radius: 50%;
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
}
</style>

View file

@ -18,9 +18,9 @@ describe('CanvasHandlePlus', () => {
expect(html()).toMatchSnapshot(); expect(html()).toMatchSnapshot();
}); });
it('emits click:plus event when plus icon is clicked', async () => { it('should emit click:plus event when plus icon is clicked', async () => {
const { container, emitted } = renderComponent(); const { container, emitted } = renderComponent();
const plusIcon = container.querySelector('svg.plus'); const plusIcon = container.querySelector('.plus');
if (!plusIcon) throw new Error('Plus icon not found'); if (!plusIcon) throw new Error('Plus icon not found');
@ -29,7 +29,7 @@ describe('CanvasHandlePlus', () => {
expect(emitted()).toHaveProperty('click:plus'); expect(emitted()).toHaveProperty('click:plus');
}); });
it('applies correct classes based on position prop', () => { it('should apply correct classes based on position prop', () => {
const positions = ['top', 'right', 'bottom', 'left']; const positions = ['top', 'right', 'bottom', 'left'];
positions.forEach((position) => { positions.forEach((position) => {
@ -40,15 +40,17 @@ describe('CanvasHandlePlus', () => {
}); });
}); });
it('renders SVG elements correctly', () => { it('should render SVG elements correctly', () => {
const { container } = renderComponent(); const { container } = renderComponent();
const lineSvg = container.querySelector('svg.line'); const svg = container.querySelector('svg');
expect(lineSvg).toBeTruthy(); expect(svg).toBeTruthy();
expect(lineSvg?.getAttribute('viewBox')).toBe('0 0 46 24'); expect(svg?.getAttribute('viewBox')).toBe('0 0 70 24');
const plusSvg = container.querySelector('svg.plus'); const lineSvg = container.querySelector('line');
expect(lineSvg).toBeTruthy();
const plusSvg = container.querySelector('.plus');
expect(plusSvg).toBeTruthy(); expect(plusSvg).toBeTruthy();
expect(plusSvg?.getAttribute('viewBox')).toBe('0 0 24 24');
}); });
}); });

View file

@ -4,9 +4,11 @@ import { computed, useCssModule } from 'vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
position?: 'top' | 'right' | 'bottom' | 'left'; position?: 'top' | 'right' | 'bottom' | 'left';
handleClasses?: string;
}>(), }>(),
{ {
position: 'right', position: 'right',
handleClasses: undefined,
}, },
); );
@ -16,7 +18,64 @@ const emit = defineEmits<{
const style = useCssModule(); const style = useCssModule();
const classes = computed(() => [style.wrapper, style[props.position]]); const classes = computed(() => [style.wrapper, style[props.position], props.handleClasses]);
const plusSize = 24;
const lineSize = 46;
const viewBox = computed(() => {
switch (props.position) {
case 'bottom':
case 'top':
return {
width: plusSize,
height: lineSize + plusSize,
};
default:
return {
width: lineSize + plusSize,
height: plusSize,
};
}
});
const linePosition = computed(() => {
switch (props.position) {
case 'top':
return [
[viewBox.value.width / 2, viewBox.value.height - lineSize + 1],
[viewBox.value.width / 2, viewBox.value.height],
];
case 'bottom':
return [
[viewBox.value.width / 2, 0],
[viewBox.value.width / 2, lineSize + 1],
];
case 'left':
return [
[viewBox.value.width - lineSize - 1, viewBox.value.height / 2],
[viewBox.value.width, viewBox.value.height / 2],
];
default:
return [
[0, viewBox.value.height / 2],
[lineSize + 1, viewBox.value.height / 2],
];
}
});
const plusPosition = computed(() => {
switch (props.position) {
case 'bottom':
return [0, viewBox.value.height - plusSize];
case 'top':
return [0, 0];
case 'left':
return [0, 0];
default:
return [viewBox.value.width - plusSize, 0];
}
});
function onClick(event: MouseEvent) { function onClick(event: MouseEvent) {
emit('click:plus', event); emit('click:plus', event);
@ -24,19 +83,23 @@ function onClick(event: MouseEvent) {
</script> </script>
<template> <template>
<div :class="classes"> <svg :class="classes" :viewBox="`0 0 ${viewBox.width} ${viewBox.height}`">
<svg :class="$style.line" viewBox="0 0 46 24"> <line
<line :class="handleClasses"
x1="0" :x1="linePosition[0][0]"
y1="12" :y1="linePosition[0][1]"
x2="46" :x2="linePosition[1][0]"
y2="12" :y2="linePosition[1][1]"
stroke="var(--color-foreground-xdark)" stroke="var(--color-foreground-xdark)"
stroke-width="2" stroke-width="2"
/> />
</svg> <g
<svg :class="$style.plus" viewBox="0 0 24 24" @click="onClick"> :class="[$style.plus, handleClasses, 'clickable']"
:transform="`translate(${plusPosition[0]}, ${plusPosition[1]})`"
@click="onClick"
>
<rect <rect
:class="[handleClasses, 'clickable']"
x="2" x="2"
y="2" y="2"
width="20" width="20"
@ -46,36 +109,33 @@ function onClick(event: MouseEvent) {
rx="4" rx="4"
fill="#ffffff" fill="#ffffff"
/> />
<g transform="translate(0, 0)"> <path
<path :class="[handleClasses, 'clickable']"
fill="var(--color-foreground-xdark)" fill="var(--color-foreground-xdark)"
d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"
></path> ></path>
</g> </g>
</svg> </svg>
</div>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.wrapper { .wrapper {
position: relative; position: relative;
width: 70px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.line { &.top,
width: 46px; &.bottom {
height: 24px; width: 24px;
height: 70px;
}
&.left,
&.right {
width: 70px;
height: 24px;
}
} }
.plus { .plus {
width: 24px;
height: 24px;
margin-left: -1px;
&:hover { &:hover {
cursor: pointer; cursor: pointer;

View file

@ -0,0 +1,21 @@
import CanvasHandleRectangle from './CanvasHandleRectangle.vue';
import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(CanvasHandleRectangle, {});
describe('CanvasHandleRectangle', () => {
it('should render with default props', () => {
const { html } = renderComponent();
expect(html()).toMatchSnapshot();
});
it('should apply `handleClasses` prop correctly', () => {
const customClass = 'custom-handle-class';
const wrapper = renderComponent({
props: { handleClasses: customClass },
});
expect(wrapper.container.querySelector(`.${customClass}`)).toBeTruthy();
});
});

View file

@ -0,0 +1,26 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
handleClasses?: string;
}>(),
{
handleClasses: undefined,
},
);
</script>
<template>
<div :class="[$style.rectangle, handleClasses]" />
</template>
<style lang="scss" module>
.rectangle {
width: var(--handle--indicator--width);
height: var(--handle--indicator--height);
background: var(--color-foreground-xdark);
&:hover {
background: var(--color-primary);
}
}
</style>

View file

@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleDiamond > should render with default props 1`] = `
"<svg class="wrapper right" viewBox="0 0 70 24">
<line class="" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line>
<g class="plus clickable" transform="translate(46, 0)">
<rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect>
<path class="clickable" fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path>
</g>
</svg>"
`;

View file

@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleDot > should render with default props 1`] = `"<div class="dot"></div>"`;

View file

@ -1,12 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandlePlus > should render with default props 1`] = ` exports[`CanvasHandlePlus > should render with default props 1`] = `
"<div class="wrapper right"><svg class="line" viewBox="0 0 46 24"> "<svg class="wrapper right" viewBox="0 0 70 24">
<line x1="0" y1="12" x2="46" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line> <line class="" x1="0" y1="12" x2="47" y2="12" stroke="var(--color-foreground-xdark)" stroke-width="2"></line>
</svg><svg class="plus" viewBox="0 0 24 24"> <g class="plus clickable" transform="translate(46, 0)">
<rect x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect> <rect class="clickable" x="2" y="2" width="20" height="20" stroke="var(--color-foreground-xdark)" stroke-width="2" rx="4" fill="#ffffff"></rect>
<g transform="translate(0, 0)"> <path class="clickable" fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path>
<path fill="var(--color-foreground-xdark)" d="m16.40655,10.89837l-3.30491,0l0,-3.30491c0,-0.40555 -0.32889,-0.73443 -0.73443,-0.73443l-0.73443,0c-0.40554,0 -0.73442,0.32888 -0.73442,0.73443l0,3.30491l-3.30491,0c-0.40555,0 -0.73443,0.32888 -0.73443,0.73442l0,0.73443c0,0.40554 0.32888,0.73443 0.73443,0.73443l3.30491,0l0,3.30491c0,0.40554 0.32888,0.73442 0.73442,0.73442l0.73443,0c0.40554,0 0.73443,-0.32888 0.73443,-0.73442l0,-3.30491l3.30491,0c0.40554,0 0.73442,-0.32889 0.73442,-0.73443l0,-0.73443c0,-0.40554 -0.32888,-0.73442 -0.73442,-0.73442z"></path> </g>
</g> </svg>"
</svg></div>"
`; `;

View file

@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasHandleRectangle > should render with default props 1`] = `"<div class="rectangle"></div>"`;

View file

@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, provide, toRef, watch } from 'vue'; import { computed, provide, toRef, watch } from 'vue';
import type { import type {
CanvasNodeData,
CanvasConnectionPort, CanvasConnectionPort,
CanvasElementPortWithRenderData, CanvasElementPortWithRenderData,
CanvasNodeData,
} from '@/types'; } from '@/types';
import { CanvasConnectionMode } from '@/types'; import { CanvasConnectionMode } from '@/types';
import NodeIcon from '@/components/NodeIcon.vue'; import NodeIcon from '@/components/NodeIcon.vue';
@ -14,8 +14,10 @@ import CanvasHandleRenderer from '@/components/canvas/elements/handles/CanvasHan
import { useNodeConnections } from '@/composables/useNodeConnections'; import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants'; import { CanvasNodeKey } from '@/constants';
import { useContextMenu } from '@/composables/useContextMenu'; import { useContextMenu } from '@/composables/useContextMenu';
import type { NodeProps, XYPosition } from '@vue-flow/core';
import { Position } from '@vue-flow/core'; import { Position } from '@vue-flow/core';
import type { XYPosition, NodeProps } from '@vue-flow/core'; import { useCanvas } from '@/composables/useCanvas';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
type Props = NodeProps<CanvasNodeData> & { type Props = NodeProps<CanvasNodeData> & {
readOnly?: boolean; readOnly?: boolean;
@ -38,6 +40,8 @@ const props = defineProps<Props>();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const { connectingHandle } = useCanvas();
const inputs = computed(() => props.data.inputs); const inputs = computed(() => props.data.inputs);
const outputs = computed(() => props.data.outputs); const outputs = computed(() => props.data.outputs);
const connections = computed(() => props.data.connections); const connections = computed(() => props.data.connections);
@ -127,9 +131,23 @@ const createEndpointMappingFn =
index: number, index: number,
endpoints: CanvasConnectionPort[], endpoints: CanvasConnectionPort[],
): CanvasElementPortWithRenderData => { ): CanvasElementPortWithRenderData => {
const handleId = createCanvasConnectionHandleString({
mode,
type: endpoint.type,
index: endpoint.index,
});
const handleType = mode === CanvasConnectionMode.Input ? 'target' : 'source';
const isConnected = !!connections.value[mode][endpoint.type]?.[endpoint.index]?.length;
const isConnecting =
connectingHandle.value?.nodeId === props.id &&
connectingHandle.value?.handleType === handleType &&
connectingHandle.value?.handleId === handleId;
return { return {
...endpoint, ...endpoint,
connected: !!connections.value[mode][endpoint.type]?.[endpoint.index]?.length, handleId,
isConnected,
isConnecting,
position, position,
offset: { offset: {
[offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`, [offsetAxis]: `${(100 / (endpoints.length + 1)) * (index + 1)}%`,
@ -214,37 +232,33 @@ watch(
:class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]" :class="[$style.canvasNode, { [$style.showToolbar]: showToolbar }]"
data-test-id="canvas-node" data-test-id="canvas-node"
> >
<template <template v-for="source in mappedOutputs" :key="source.handleId">
v-for="source in mappedOutputs"
:key="`${CanvasConnectionMode.Output}/${source.type}/${source.index}`"
>
<CanvasHandleRenderer <CanvasHandleRenderer
data-test-id="canvas-node-output-handle" data-test-id="canvas-node-output-handle"
:connected="source.connected"
:mode="CanvasConnectionMode.Output" :mode="CanvasConnectionMode.Output"
:type="source.type" :type="source.type"
:label="source.label" :label="source.label"
:index="source.index" :index="source.index"
:position="source.position" :position="source.position"
:offset="source.offset" :offset="source.offset"
:is-connected="source.isConnected"
:is-connecting="source.isConnecting"
:is-valid-connection="isValidConnection" :is-valid-connection="isValidConnection"
@add="onAdd" @add="onAdd"
/> />
</template> </template>
<template <template v-for="target in mappedInputs" :key="target.handleId">
v-for="target in mappedInputs"
:key="`${CanvasConnectionMode.Input}/${target.type}/${target.index}`"
>
<CanvasHandleRenderer <CanvasHandleRenderer
data-test-id="canvas-node-input-handle" data-test-id="canvas-node-input-handle"
:connected="!!connections[CanvasConnectionMode.Input][target.type]?.[target.index]?.length"
:mode="CanvasConnectionMode.Input" :mode="CanvasConnectionMode.Input"
:type="target.type" :type="target.type"
:label="target.label" :label="target.label"
:index="target.index" :index="target.index"
:position="target.position" :position="target.position"
:offset="target.offset" :offset="target.offset"
:is-connected="target.isConnected"
:is-connecting="target.isConnecting"
:is-valid-connection="isValidConnection" :is-valid-connection="isValidConnection"
@add="onAdd" @add="onAdd"
/> />
@ -296,5 +310,6 @@ watch(
left: 50%; left: 50%;
transform: translate(-50%, -100%); transform: translate(-50%, -100%);
opacity: 0; opacity: 0;
z-index: 1;
} }
</style> </style>

View file

@ -0,0 +1,12 @@
import { computed, inject } from 'vue';
import { CanvasKey } from '@/constants';
export function useCanvas() {
const canvas = inject(CanvasKey);
const connectingHandle = computed(() => canvas?.connectingHandle.value);
return {
connectingHandle,
};
}

View file

@ -12,13 +12,15 @@ export function useCanvasNodeHandle() {
const handle = inject(CanvasNodeHandleKey); const handle = inject(CanvasNodeHandleKey);
const label = computed(() => handle?.label.value ?? ''); const label = computed(() => handle?.label.value ?? '');
const connected = computed(() => handle?.connected.value ?? false); const isConnected = computed(() => handle?.isConnected.value ?? false);
const isConnecting = computed(() => handle?.isConnecting.value ?? false);
const type = computed(() => handle?.type.value ?? NodeConnectionType.Main); const type = computed(() => handle?.type.value ?? NodeConnectionType.Main);
const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input); const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input);
return { return {
label, label,
connected, isConnected,
isConnecting,
type, type,
mode, mode,
}; };

View file

@ -4,7 +4,11 @@ import type {
NodeCreatorOpenSource, NodeCreatorOpenSource,
} from './Interface'; } from './Interface';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import type { CanvasNodeHandleInjectionData, CanvasNodeInjectionData } from '@/types'; import type {
CanvasInjectionData,
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
} from '@/types';
import type { InjectionKey } from 'vue'; import type { InjectionKey } from 'vue';
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
@ -856,6 +860,7 @@ export const INSECURE_CONNECTION_WARNING = `
* Injection Keys * Injection Keys
*/ */
export const CanvasKey = 'canvas' as unknown as InjectionKey<CanvasInjectionData>;
export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>; export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeInjectionData>;
export const CanvasNodeHandleKey = export const CanvasNodeHandleKey =
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>; 'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;

View file

@ -30,7 +30,9 @@ export type CanvasConnectionPort = {
}; };
export interface CanvasElementPortWithRenderData extends CanvasConnectionPort { export interface CanvasElementPortWithRenderData extends CanvasConnectionPort {
connected: boolean; handleId: string;
isConnected: boolean;
isConnecting: boolean;
position: Position; position: Position;
offset?: { top?: string; left?: string }; offset?: { top?: string; left?: string };
} }
@ -118,6 +120,10 @@ export type CanvasConnectionCreateData = {
}; };
}; };
export interface CanvasInjectionData {
connectingHandle: Ref<ConnectStartEvent | undefined>;
}
export interface CanvasNodeInjectionData { export interface CanvasNodeInjectionData {
id: Ref<string>; id: Ref<string>;
data: Ref<CanvasNodeData>; data: Ref<CanvasNodeData>;
@ -129,7 +135,8 @@ export interface CanvasNodeHandleInjectionData {
label: Ref<string | undefined>; label: Ref<string | undefined>;
mode: Ref<CanvasConnectionMode>; mode: Ref<CanvasConnectionMode>;
type: Ref<NodeConnectionType>; type: Ref<NodeConnectionType>;
connected: Ref<boolean | undefined>; isConnected: Ref<boolean | undefined>;
isConnecting: Ref<boolean | undefined>;
} }
export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string }; export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string };

View file

@ -726,7 +726,12 @@ function onRevertCreateConnection({ connection }: { connection: [IConnection, IC
revertCreateConnection(connection); revertCreateConnection(connection);
} }
function onCreateConnectionCancelled(event: ConnectStartEvent) { function onCreateConnectionCancelled(event: ConnectStartEvent, mouseEvent?: MouseEvent) {
const preventDefault = (mouseEvent?.target as HTMLElement).classList?.contains('clickable');
if (preventDefault) {
return;
}
setTimeout(() => { setTimeout(() => {
nodeCreatorStore.openNodeCreatorForConnectingNode({ nodeCreatorStore.openNodeCreatorForConnectingNode({
connection: { connection: {