Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 27 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
35b4713
support for drag and drop at the end of prompt
michael-radency Oct 16, 2024
9ca133e
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Oct 16, 2024
634f7f1
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 19, 2024
23e0308
merge fix
michael-radency Nov 19, 2024
192a507
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 19, 2024
a81376f
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 20, 2024
4f913fc
onDrop update
michael-radency Nov 20, 2024
c8821a6
renamed vars
michael-radency Nov 20, 2024
e3c28af
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 20, 2024
8eae27b
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 20, 2024
e4f0de7
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 20, 2024
e752d21
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 21, 2024
3151090
split textarea into rows
michael-radency Nov 21, 2024
2140961
lines to rows mapping
michael-radency Nov 21, 2024
47eb92d
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 21, 2024
3dde184
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 22, 2024
ebdf486
cursor highlight
michael-radency Nov 22, 2024
9548ccd
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 22, 2024
31da9e7
refactoring
michael-radency Nov 22, 2024
7afadc1
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 22, 2024
d72d6d1
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 26, 2024
64c534f
row snap position update
michael-radency Nov 26, 2024
b816891
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 26, 2024
679369e
test fix
michael-radency Nov 26, 2024
e89e25f
refactoring
michael-radency Nov 27, 2024
6cf7309
renamed vars
michael-radency Nov 27, 2024
b26ccb0
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1863…
michael-radency Nov 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe('ButtonParameter', () => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [{}],
activeNode: { name: 'TestNode', parameters: {} },
isDraggableDragging: false,
} as any);

vi.mocked(useWorkflowsStore).mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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);
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -227,7 +276,7 @@ onMounted(() => {

<style module lang="scss">
.input * {
border: 0 !important;
border: 1.5px transparent !important;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be done without !important ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, we need to override defaults of N8nInput(ElInput)

}
.input textarea {
font-size: var(--font-size-2xs);
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be done without !important ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, we need to override defaults of N8nInput(ElInput)

cursor: grabbing;
}
</style>
166 changes: 166 additions & 0 deletions packages/editor-ui/src/components/ButtonParameter/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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