feat(AI Transform Node): Support for drag and drop (#11276)

This commit is contained in:
Michael Kret 2024-11-27 12:42:55 +02:00 committed by GitHub
parent 80c5242e16
commit 2c252b0b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 235 additions and 12 deletions

View file

@ -45,6 +45,7 @@ describe('ButtonParameter', () => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [{}],
activeNode: { name: 'TestNode', parameters: {} },
isDraggableDragging: false,
} as any);
vi.mocked(useWorkflowsStore).mockReturnValue({

View file

@ -6,10 +6,18 @@ import { N8nButton, N8nInput, N8nTooltip } from 'n8n-design-system/components';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
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 { useUIStore } from '@/stores/ui.store';
import { propertyNameFromExpression } from '../../utils/mappingUtils';
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
const emit = defineEmits<{
@ -29,6 +37,7 @@ const i18n = useI18n();
const isLoading = ref(false);
const prompt = ref(props.value);
const parentNodes = ref<INodeUi[]>([]);
const textareaRowsData = ref<TextareaRowData | null>(null);
const hasExecutionData = computed(() => (useNDVStore().ndvInputData || []).length > 0);
const hasInputField = computed(() => props.parameter.typeOptions?.buttonConfig?.hasInputField);
@ -159,6 +168,37 @@ function useDarkBackdrop(): string {
onMounted(() => {
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>
<template>
@ -186,16 +226,25 @@ onMounted(() => {
v-text="'Instructions changed'"
/>
</div>
<N8nInput
v-model="prompt"
:class="$style.input"
style="border: 1px solid var(--color-foreground-base)"
type="textarea"
:rows="6"
:maxlength="inputFieldMaxLength"
:placeholder="parameter.placeholder"
@input="onPromptInput"
/>
<DraggableTarget type="mapping" :disabled="isLoading" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<N8nInput
v-model="prompt"
:class="[
$style.input,
{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable },
]"
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 :class="$style.controls">
<N8nTooltip :disabled="isSubmitEnabled">
@ -227,7 +276,7 @@ onMounted(() => {
<style module lang="scss">
.input * {
border: 0 !important;
border: 1.5px transparent !important;
}
.input textarea {
font-size: var(--font-size-2xs);
@ -277,4 +326,11 @@ onMounted(() => {
color: var(--color-warning);
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>

View file

@ -12,6 +12,11 @@ import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
export type TextareaRowData = {
rows: string[];
linesToRowsMap: number[][];
};
export function getParentNodes() {
const activeNode = useNDVStore().activeNode;
const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore();
@ -89,3 +94,164 @@ export async function generateCodeForAiTransform(prompt: string, path: string) {
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;
}