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({
|
||||
ndvInputData: [{}],
|
||||
activeNode: { name: 'TestNode', parameters: {} },
|
||||
isDraggableDragging: false,
|
||||
} as any);
|
||||
|
||||
vi.mocked(useWorkflowsStore).mockReturnValue({
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue