mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(AI Transform Node): Support for drag and drop (#11276)
This commit is contained in:
parent
80c5242e16
commit
2c252b0b2d
|
@ -45,6 +45,7 @@ describe('ButtonParameter', () => {
|
||||||
vi.mocked(useNDVStore).mockReturnValue({
|
vi.mocked(useNDVStore).mockReturnValue({
|
||||||
ndvInputData: [{}],
|
ndvInputData: [{}],
|
||||||
activeNode: { name: 'TestNode', parameters: {} },
|
activeNode: { name: 'TestNode', parameters: {} },
|
||||||
|
isDraggableDragging: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
vi.mocked(useWorkflowsStore).mockReturnValue({
|
vi.mocked(useWorkflowsStore).mockReturnValue({
|
|
@ -6,10 +6,18 @@ import { N8nButton, N8nInput, N8nTooltip } from 'n8n-design-system/components';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { getParentNodes, generateCodeForAiTransform } from './utils';
|
import {
|
||||||
|
getParentNodes,
|
||||||
|
generateCodeForAiTransform,
|
||||||
|
type TextareaRowData,
|
||||||
|
getUpdatedTextareaValue,
|
||||||
|
getTextareaCursorPosition,
|
||||||
|
} from './utils';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
|
import { propertyNameFromExpression } from '../../utils/mappingUtils';
|
||||||
|
|
||||||
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -29,6 +37,7 @@ const i18n = useI18n();
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const prompt = ref(props.value);
|
const prompt = ref(props.value);
|
||||||
const parentNodes = ref<INodeUi[]>([]);
|
const parentNodes = ref<INodeUi[]>([]);
|
||||||
|
const textareaRowsData = ref<TextareaRowData | null>(null);
|
||||||
|
|
||||||
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
|
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
|
||||||
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
|
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
|
||||||
|
@ -159,6 +168,37 @@ function useDarkBackdrop(): string {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
parentNodes.value = getParentNodes();
|
parentNodes.value = getParentNodes();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function cleanTextareaRowsData() {
|
||||||
|
textareaRowsData.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(value: string, event: MouseEvent) {
|
||||||
|
value = propertyNameFromExpression(value);
|
||||||
|
|
||||||
|
prompt.value = getUpdatedTextareaValue(event, textareaRowsData.value, value);
|
||||||
|
|
||||||
|
emit('valueChanged', {
|
||||||
|
name: getPath(props.parameter.name),
|
||||||
|
value: prompt.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCursorPositionOnMouseMove(event: MouseEvent, activeDrop: boolean) {
|
||||||
|
if (!activeDrop) return;
|
||||||
|
|
||||||
|
const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
const position = getTextareaCursorPosition(
|
||||||
|
textarea,
|
||||||
|
textareaRowsData.value,
|
||||||
|
event.clientX,
|
||||||
|
event.clientY,
|
||||||
|
);
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(position, position);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -186,16 +226,25 @@ onMounted(() => {
|
||||||
v-text="'Instructions changed'"
|
v-text="'Instructions changed'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<N8nInput
|
<DraggableTarget type="mapping" :disabled="isLoading" @drop="onDrop">
|
||||||
v-model="prompt"
|
<template #default="{ activeDrop, droppable }">
|
||||||
:class="$style.input"
|
<N8nInput
|
||||||
style="border: 1px solid var(--color-foreground-base)"
|
v-model="prompt"
|
||||||
type="textarea"
|
:class="[
|
||||||
:rows="6"
|
$style.input,
|
||||||
:maxlength="inputFieldMaxLength"
|
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
|
||||||
:placeholder="parameter.placeholder"
|
]"
|
||||||
@input="onPromptInput"
|
style="border: 1.5px solid var(--color-foreground-base)"
|
||||||
/>
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
:maxlength="inputFieldMaxLength"
|
||||||
|
:placeholder="parameter.placeholder"
|
||||||
|
@input="onPromptInput"
|
||||||
|
@mousemove="updateCursorPositionOnMouseMove($event, activeDrop)"
|
||||||
|
@mouseleave="cleanTextareaRowsData"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DraggableTarget>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.controls">
|
<div :class="$style.controls">
|
||||||
<N8nTooltip :disabled="isSubmitEnabled">
|
<N8nTooltip :disabled="isSubmitEnabled">
|
||||||
|
@ -227,7 +276,7 @@ onMounted(() => {
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.input * {
|
.input * {
|
||||||
border: 0 !important;
|
border: 1.5px transparent !important;
|
||||||
}
|
}
|
||||||
.input textarea {
|
.input textarea {
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
|
@ -277,4 +326,11 @@ onMounted(() => {
|
||||||
color: var(--color-warning);
|
color: var(--color-warning);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
.droppable {
|
||||||
|
border: 1.5px dashed var(--color-ndv-droppable-parameter) !important;
|
||||||
|
}
|
||||||
|
.activeDrop {
|
||||||
|
border: 1.5px solid var(--color-success) !important;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -12,6 +12,11 @@ import { format } from 'prettier';
|
||||||
import jsParser from 'prettier/plugins/babel';
|
import jsParser from 'prettier/plugins/babel';
|
||||||
import * as estree from 'prettier/plugins/estree';
|
import * as estree from 'prettier/plugins/estree';
|
||||||
|
|
||||||
|
export type TextareaRowData = {
|
||||||
|
rows: string[];
|
||||||
|
linesToRowsMap: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
export function getParentNodes() {
|
export function getParentNodes() {
|
||||||
const activeNode = useNDVStore().activeNode;
|
const activeNode = useNDVStore().activeNode;
|
||||||
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
|
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
|
||||||
|
@ -89,3 +94,164 @@ export async function generateCodeForAiTransform(prompt: string, path: string) {
|
||||||
|
|
||||||
return updateInformation;
|
return updateInformation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//------ drag and drop ------
|
||||||
|
|
||||||
|
function splitText(textarea: HTMLTextAreaElement, textareaRowsData: TextareaRowData | null) {
|
||||||
|
if (textareaRowsData) return textareaRowsData;
|
||||||
|
const rows: string[] = [];
|
||||||
|
const linesToRowsMap: number[][] = [];
|
||||||
|
const style = window.getComputedStyle(textarea);
|
||||||
|
|
||||||
|
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||||
|
const border = parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||||
|
const textareaWidth = textarea.clientWidth - padding - border;
|
||||||
|
|
||||||
|
const context = createTextContext(style);
|
||||||
|
|
||||||
|
const lines = textarea.value.split('\n');
|
||||||
|
|
||||||
|
lines.forEach((_) => {
|
||||||
|
linesToRowsMap.push([]);
|
||||||
|
});
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (line === '') {
|
||||||
|
rows.push(line);
|
||||||
|
linesToRowsMap[index].push(rows.length - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let currentLine = '';
|
||||||
|
const words = line.split(/(\s+)/);
|
||||||
|
|
||||||
|
words.forEach((word) => {
|
||||||
|
const testLine = currentLine + word;
|
||||||
|
const testWidth = context.measureText(testLine).width;
|
||||||
|
|
||||||
|
if (testWidth <= textareaWidth) {
|
||||||
|
currentLine = testLine;
|
||||||
|
} else {
|
||||||
|
rows.push(currentLine.trimEnd());
|
||||||
|
linesToRowsMap[index].push(rows.length - 1);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentLine) {
|
||||||
|
rows.push(currentLine.trimEnd());
|
||||||
|
linesToRowsMap[index].push(rows.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rows, linesToRowsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextContext(style: CSSStyleDeclaration): CanvasRenderingContext2D {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const context = canvas.getContext('2d')!;
|
||||||
|
context.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowIndex = (textareaY: number, lineHeight: string) => {
|
||||||
|
const rowHeight = parseInt(lineHeight, 10);
|
||||||
|
const snapPosition = textareaY - rowHeight / 2 - 1;
|
||||||
|
return Math.floor(snapPosition / rowHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColumnIndex = (rowText: string, textareaX: number, font: string) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.style.font = font;
|
||||||
|
span.style.visibility = 'hidden';
|
||||||
|
span.style.position = 'absolute';
|
||||||
|
span.style.whiteSpace = 'pre';
|
||||||
|
document.body.appendChild(span);
|
||||||
|
|
||||||
|
let left = 0;
|
||||||
|
let right = rowText.length;
|
||||||
|
let col = 0;
|
||||||
|
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
span.textContent = rowText.substring(0, mid);
|
||||||
|
const width = span.getBoundingClientRect().width;
|
||||||
|
|
||||||
|
if (width <= textareaX) {
|
||||||
|
col = mid;
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(span);
|
||||||
|
|
||||||
|
return rowText.length === col ? col : col - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getUpdatedTextareaValue(
|
||||||
|
event: MouseEvent,
|
||||||
|
textareaRowsData: TextareaRowData | null,
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
const rect = textarea.getBoundingClientRect();
|
||||||
|
const textareaX = event.clientX - rect.left;
|
||||||
|
const textareaY = event.clientY - rect.top;
|
||||||
|
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||||
|
|
||||||
|
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||||
|
|
||||||
|
const rowsData = splitText(textarea, textareaRowsData);
|
||||||
|
|
||||||
|
let newText = value;
|
||||||
|
|
||||||
|
if (rowsData.rows[rowIndex] === undefined) {
|
||||||
|
newText = `${textarea.value} ${value}`;
|
||||||
|
}
|
||||||
|
const { rows, linesToRowsMap } = rowsData;
|
||||||
|
const rowText = rows[rowIndex];
|
||||||
|
|
||||||
|
if (rowText === '') {
|
||||||
|
rows[rowIndex] = value;
|
||||||
|
} else {
|
||||||
|
const col = getColumnIndex(rowText, textareaX, font);
|
||||||
|
rows[rowIndex] = [rows[rowIndex].slice(0, col).trim(), value, rows[rowIndex].slice(col).trim()]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
newText = linesToRowsMap
|
||||||
|
.map((lineMap) => {
|
||||||
|
return lineMap.map((index) => rows[index]).join(' ');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return newText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextareaCursorPosition(
|
||||||
|
textarea: HTMLTextAreaElement,
|
||||||
|
textareaRowsData: TextareaRowData | null,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
) {
|
||||||
|
const rect = textarea.getBoundingClientRect();
|
||||||
|
const textareaX = clientX - rect.left;
|
||||||
|
const textareaY = clientY - rect.top;
|
||||||
|
const { lineHeight, font } = window.getComputedStyle(textarea);
|
||||||
|
|
||||||
|
const rowIndex = getRowIndex(textareaY, lineHeight);
|
||||||
|
const { rows } = splitText(textarea, textareaRowsData);
|
||||||
|
|
||||||
|
if (rowIndex < 0 || rowIndex >= rows.length) {
|
||||||
|
return textarea.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowText = rows[rowIndex];
|
||||||
|
|
||||||
|
const col = getColumnIndex(rowText, textareaX, font);
|
||||||
|
|
||||||
|
const position = rows.slice(0, rowIndex).reduce((acc, curr) => acc + curr.length + 1, 0) + col;
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue