mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat(editor): Overhaul handle rendering to allow dragging of plus button (no-changelog) (#10512)
This commit is contained in:
parent
cd0c6d9b55
commit
351d8413e9
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
|
@ -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>"
|
||||||
|
`;
|
|
@ -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>"`;
|
|
@ -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>"
|
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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>"`;
|
|
@ -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>
|
||||||
|
|
12
packages/editor-ui/src/composables/useCanvas.ts
Normal file
12
packages/editor-ui/src/composables/useCanvas.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Reference in a new issue