mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Improve ai nodes, handles and connections design in new canvas (no-changelog) (#10969)
This commit is contained in:
parent
a62a9db8fa
commit
fe871d89a9
|
@ -125,6 +125,7 @@ export function createCanvasHandleProvide({
|
|||
isConnected = false,
|
||||
isConnecting = false,
|
||||
isReadOnly = false,
|
||||
isRequired = false,
|
||||
}: {
|
||||
label?: string;
|
||||
mode?: CanvasConnectionMode;
|
||||
|
@ -134,6 +135,7 @@ export function createCanvasHandleProvide({
|
|||
isConnected?: boolean;
|
||||
isConnecting?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isRequired?: boolean;
|
||||
} = {}) {
|
||||
return {
|
||||
[String(CanvasNodeHandleKey)]: {
|
||||
|
@ -143,8 +145,9 @@ export function createCanvasHandleProvide({
|
|||
index: ref(index),
|
||||
isConnected: ref(isConnected),
|
||||
isConnecting: ref(isConnecting),
|
||||
runData: ref(runData),
|
||||
isReadOnly: ref(isReadOnly),
|
||||
isRequired: ref(isRequired),
|
||||
runData: ref(runData),
|
||||
} satisfies CanvasNodeHandleInjectionData,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
|||
import type { Connection, EdgeProps } from '@vue-flow/core';
|
||||
import { useVueFlow, BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { computed, useCssModule, ref } from 'vue';
|
||||
import { computed, useCssModule, ref, toRef } from 'vue';
|
||||
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
|
||||
import { getCustomPath } from './utils/edgePath';
|
||||
|
||||
|
@ -21,6 +21,8 @@ export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
|
|||
|
||||
const props = defineProps<CanvasEdgeProps>();
|
||||
|
||||
const data = toRef(props, 'data');
|
||||
|
||||
const { onEdgeMouseEnter, onEdgeMouseLeave } = useVueFlow();
|
||||
|
||||
const isHovered = ref(false);
|
||||
|
@ -44,6 +46,8 @@ const connectionType = computed(() =>
|
|||
|
||||
const renderToolbar = computed(() => (props.selected || isHovered.value) && !props.readOnly);
|
||||
|
||||
const isMainConnection = computed(() => data.value.source.type === NodeConnectionType.Main);
|
||||
|
||||
const status = computed(() => props.data.status);
|
||||
const statusColor = computed(() => {
|
||||
if (props.selected) {
|
||||
|
@ -54,6 +58,8 @@ const statusColor = computed(() => {
|
|||
return 'var(--color-secondary)';
|
||||
} else if (status.value === 'running') {
|
||||
return 'var(--color-primary)';
|
||||
} else if (!isMainConnection.value) {
|
||||
return 'var(--node-type-supplemental-color)';
|
||||
} else {
|
||||
return 'var(--color-foreground-xdark)';
|
||||
}
|
||||
|
@ -61,6 +67,7 @@ const statusColor = computed(() => {
|
|||
|
||||
const edgeStyle = computed(() => ({
|
||||
...props.style,
|
||||
...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }),
|
||||
strokeWidth: 2,
|
||||
stroke: isHovered.value ? 'var(--color-primary)' : statusColor.value,
|
||||
}));
|
||||
|
|
|
@ -20,6 +20,7 @@ const props = defineProps<{
|
|||
isConnecting?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
type: CanvasConnectionPort['type'];
|
||||
index: CanvasConnectionPort['index'];
|
||||
position: CanvasElementPortWithRenderData['position'];
|
||||
|
@ -116,6 +117,7 @@ const isReadOnly = toRef(props, 'isReadOnly');
|
|||
const mode = toRef(props, 'mode');
|
||||
const type = toRef(props, 'type');
|
||||
const index = toRef(props, 'index');
|
||||
const isRequired = toRef(props, 'required');
|
||||
|
||||
provide(CanvasNodeHandleKey, {
|
||||
label,
|
||||
|
@ -123,6 +125,7 @@ provide(CanvasNodeHandleKey, {
|
|||
type,
|
||||
index,
|
||||
runData,
|
||||
isRequired,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
isReadOnly,
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
<script lang="ts" setup>
|
||||
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
|
||||
const { label } = useCanvasNodeHandle();
|
||||
const $style = useCssModule();
|
||||
|
||||
const { label, isRequired } = useCanvasNodeHandle();
|
||||
|
||||
const classes = computed(() => ({
|
||||
'canvas-node-handle-main-input': true,
|
||||
[$style.handle]: true,
|
||||
[$style.required]: isRequired.value,
|
||||
}));
|
||||
|
||||
const handleClasses = 'target';
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-main-input', $style.handle]">
|
||||
<div :class="classes">
|
||||
<div :class="[$style.label]">{{ label }}</div>
|
||||
<CanvasHandleRectangle :handle-classes="handleClasses" />
|
||||
</div>
|
||||
|
@ -32,4 +41,9 @@ const handleClasses = 'target';
|
|||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.required .label::after {
|
||||
content: '*';
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
|
||||
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, useCssModule } from 'vue';
|
||||
import type { CanvasNodeDefaultRender } from '@/types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
|
@ -9,11 +9,20 @@ const emit = defineEmits<{
|
|||
add: [];
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { render } = useCanvasNode();
|
||||
const { label, isConnected, isConnecting, isReadOnly, runData } = useCanvasNodeHandle();
|
||||
const { label, isConnected, isConnecting, isReadOnly, isRequired, runData } = useCanvasNodeHandle();
|
||||
|
||||
const handleClasses = 'source';
|
||||
|
||||
const classes = computed(() => ({
|
||||
'canvas-node-handle-main-output': true,
|
||||
[$style.handle]: true,
|
||||
[$style.required]: isRequired.value,
|
||||
}));
|
||||
|
||||
const isHovered = ref(false);
|
||||
|
||||
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
|
||||
|
@ -29,7 +38,7 @@ const runDataLabel = computed(() =>
|
|||
|
||||
const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value);
|
||||
|
||||
const plusState = computed(() => (runData.value ? 'success' : 'default'));
|
||||
const plusStatus = computed(() => (runData.value ? 'success' : 'default'));
|
||||
|
||||
const plusLineSize = computed(
|
||||
() =>
|
||||
|
@ -53,7 +62,7 @@ function onClickAdd() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-main-output', $style.handle]">
|
||||
<div :class="classes">
|
||||
<div v-if="label" :class="[$style.label, $style.outputLabel]">{{ label }}</div>
|
||||
<div v-else-if="runData" :class="[$style.label, $style.runDataLabel]">{{ runDataLabel }}</div>
|
||||
<CanvasHandleDot :handle-classes="handleClasses" />
|
||||
|
@ -64,7 +73,7 @@ function onClickAdd() {
|
|||
data-test-id="canvas-handle-plus"
|
||||
:line-size="plusLineSize"
|
||||
:handle-classes="handleClasses"
|
||||
:state="plusState"
|
||||
:status="plusStatus"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@click:plus="onClickAdd"
|
||||
|
@ -91,6 +100,11 @@ function onClickAdd() {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.required .label::after {
|
||||
content: '*';
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.outputLabel {
|
||||
top: 50%;
|
||||
left: var(--spacing-m);
|
||||
|
|
|
@ -2,24 +2,36 @@
|
|||
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 } from 'vue';
|
||||
import { computed, ref, useCssModule } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [];
|
||||
}>();
|
||||
|
||||
const { label, isConnected, isConnecting, type } = useCanvasNodeHandle();
|
||||
const $style = useCssModule();
|
||||
|
||||
const { label, isConnected, isConnecting, isRequired, type, runData } = useCanvasNodeHandle();
|
||||
|
||||
const handleClasses = 'target';
|
||||
|
||||
const classes = computed(() => ({
|
||||
'canvas-node-handle-non-main-input': true,
|
||||
[$style.handle]: true,
|
||||
[$style.required]: isRequired.value,
|
||||
}));
|
||||
|
||||
const supportsMultipleConnections = computed(() => type.value === NodeConnectionType.AiTool);
|
||||
|
||||
const isHandlePlusAvailable = computed(
|
||||
() => !isConnected.value || supportsMultipleConnections.value,
|
||||
);
|
||||
|
||||
const isHandlePlusVisible = computed(
|
||||
() => !isConnecting.value || isHovered.value || supportsMultipleConnections.value,
|
||||
);
|
||||
|
||||
const plusStatus = computed(() => (runData.value ? 'success' : 'ai'));
|
||||
|
||||
const isHovered = ref(false);
|
||||
|
||||
function onMouseEnter() {
|
||||
|
@ -35,7 +47,7 @@ function onClickAdd() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-non-main-input', $style.handle]">
|
||||
<div :class="classes">
|
||||
<div :class="[$style.label]">{{ label }}</div>
|
||||
<CanvasHandleDiamond :handle-classes="handleClasses" />
|
||||
<Transition name="canvas-node-handle-non-main-input">
|
||||
|
@ -43,6 +55,7 @@ function onClickAdd() {
|
|||
v-if="isHandlePlusAvailable"
|
||||
v-show="isHandlePlusVisible"
|
||||
:handle-classes="handleClasses"
|
||||
:status="plusStatus"
|
||||
position="bottom"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
|
@ -66,12 +79,17 @@ function onClickAdd() {
|
|||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-foreground-xdark);
|
||||
color: var(--node-type-supplemental-color);
|
||||
background: var(--color-canvas-label-background);
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.required .label::after {
|
||||
content: '*';
|
||||
color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
<script lang="ts" setup>
|
||||
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
|
||||
import { computed, useCssModule } from 'vue';
|
||||
|
||||
const { label } = useCanvasNodeHandle();
|
||||
const $style = useCssModule();
|
||||
const { label, isRequired } = useCanvasNodeHandle();
|
||||
|
||||
const handleClasses = 'source';
|
||||
|
||||
const classes = computed(() => ({
|
||||
'canvas-node-handle-non-main-output': true,
|
||||
[$style.handle]: true,
|
||||
[$style.required]: isRequired.value,
|
||||
}));
|
||||
</script>
|
||||
<template>
|
||||
<div :class="['canvas-node-handle-non-main-output', $style.handle]">
|
||||
<div :class="[$style.label]">{{ label }}</div>
|
||||
<div :class="classes">
|
||||
<div :class="$style.label">{{ label }}</div>
|
||||
<CanvasHandleDiamond :handle-classes="handleClasses" />
|
||||
</div>
|
||||
</template>
|
||||
|
@ -23,15 +31,20 @@ const handleClasses = 'source';
|
|||
.label {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: -50%;
|
||||
transform: translate(0%, 0);
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-foreground-xdark);
|
||||
color: var(--node-type-supplemental-color);
|
||||
background: var(--color-canvas-label-background);
|
||||
z-index: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.required .label::after {
|
||||
content: '*';
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
:global(.vue-flow__handle:not(.connectionindicator)) .plus {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
|
|
@ -40,9 +40,9 @@ describe('CanvasHandlePlus', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should apply correct classes based on state', () => {
|
||||
it('should apply correct classes based on status', () => {
|
||||
const { container } = renderComponent({
|
||||
props: { state: 'success' },
|
||||
props: { status: 'success' },
|
||||
});
|
||||
|
||||
expect(container.firstChild).toHaveClass('success');
|
||||
|
|
|
@ -7,14 +7,14 @@ const props = withDefaults(
|
|||
handleClasses?: string;
|
||||
plusSize?: number;
|
||||
lineSize?: number;
|
||||
state?: 'success' | 'default';
|
||||
status?: 'success' | 'ai' | 'default';
|
||||
}>(),
|
||||
{
|
||||
position: 'right',
|
||||
handleClasses: undefined,
|
||||
plusSize: 24,
|
||||
lineSize: 46,
|
||||
state: 'default',
|
||||
status: 'default',
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -27,7 +27,7 @@ const style = useCssModule();
|
|||
const classes = computed(() => [
|
||||
style.wrapper,
|
||||
style[props.position],
|
||||
style[props.state],
|
||||
style[props.status],
|
||||
props.handleClasses,
|
||||
]);
|
||||
|
||||
|
@ -135,8 +135,26 @@ function onClick(event: MouseEvent) {
|
|||
.wrapper {
|
||||
position: relative;
|
||||
|
||||
&.success .line {
|
||||
stroke: var(--color-success);
|
||||
&.ai {
|
||||
.line {
|
||||
stroke: var(--node-type-supplemental-color);
|
||||
}
|
||||
|
||||
.plus {
|
||||
path {
|
||||
fill: var(--node-type-supplemental-color);
|
||||
}
|
||||
|
||||
rect {
|
||||
stroke: var(--node-type-supplemental-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
.line {
|
||||
stroke: var(--color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -269,6 +269,7 @@ onBeforeUnmount(() => {
|
|||
:index="source.index"
|
||||
:position="source.position"
|
||||
:offset="source.offset"
|
||||
:required="source.required"
|
||||
:is-connected="source.isConnected"
|
||||
:is-connecting="source.isConnecting"
|
||||
:is-read-only="readOnly"
|
||||
|
@ -286,6 +287,7 @@ onBeforeUnmount(() => {
|
|||
:index="target.index"
|
||||
:position="target.position"
|
||||
:offset="target.offset"
|
||||
:required="target.required"
|
||||
:is-connected="target.isConnected"
|
||||
:is-connecting="target.isConnecting"
|
||||
:is-read-only="readOnly"
|
||||
|
|
|
@ -190,6 +190,7 @@ function openContextMenu(event: MouseEvent) {
|
|||
.description {
|
||||
top: unset;
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
margin-left: var(--spacing-s);
|
||||
width: auto;
|
||||
min-width: unset;
|
||||
|
@ -199,6 +200,19 @@ function openContextMenu(event: MouseEvent) {
|
|||
) - 2 * var(--spacing-s)
|
||||
);
|
||||
}
|
||||
|
||||
.label {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.configuration {
|
||||
--canvas-node--height: 75px;
|
||||
|
||||
.statusIcons {
|
||||
right: calc(-1 * var(--spacing-2xs));
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -15,6 +15,7 @@ export function useCanvasNodeHandle() {
|
|||
const isConnected = computed(() => handle?.isConnected.value ?? false);
|
||||
const isConnecting = computed(() => handle?.isConnecting.value ?? false);
|
||||
const isReadOnly = computed(() => handle?.isReadOnly.value);
|
||||
const isRequired = computed(() => handle?.isRequired.value);
|
||||
const type = computed(() => handle?.type.value ?? NodeConnectionType.Main);
|
||||
const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input);
|
||||
const index = computed(() => handle?.index.value ?? 0);
|
||||
|
@ -25,6 +26,7 @@ export function useCanvasNodeHandle() {
|
|||
isConnected,
|
||||
isConnecting,
|
||||
isReadOnly,
|
||||
isRequired,
|
||||
type,
|
||||
mode,
|
||||
index,
|
||||
|
|
|
@ -165,6 +165,7 @@ export interface CanvasNodeHandleInjectionData {
|
|||
mode: Ref<CanvasConnectionMode>;
|
||||
type: Ref<NodeConnectionType>;
|
||||
index: Ref<number>;
|
||||
isRequired: Ref<boolean | undefined>;
|
||||
isConnected: Ref<boolean | undefined>;
|
||||
isConnecting: Ref<boolean | undefined>;
|
||||
isReadOnly: Ref<boolean | undefined>;
|
||||
|
|
Loading…
Reference in a new issue