refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import ButtonParameter, { type Props } from '@/components/ButtonParameter/ButtonParameter.vue';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { usePostHog } from '@/stores/posthog.store';
import { useRootStore } from '@/stores/root.store';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import type { INodeProperties } from 'n8n-workflow';
vi.mock('@/stores/ndv.store');
vi.mock('@/stores/workflows.store');
vi.mock('@/stores/posthog.store');
vi.mock('@/stores/root.store');
vi.mock('@/api/ai');
vi.mock('@/composables/useI18n');
vi.mock('@/composables/useToast');
describe('ButtonParameter', () => {
const defaultProps: Props = {
parameter: {
name: 'testParam',
displayName: 'Test Parameter',
type: 'string',
default: '',
typeOptions: {
buttonConfig: {
label: 'Generate',
action: {
type: 'askAiCodeGeneration',
target: 'targetParam',
},
hasInputField: true,
},
},
} as INodeProperties,
value: '',
isReadOnly: false,
path: 'testPath',
};
beforeEach(() => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [{}],
activeNode: { name: 'TestNode', parameters: {} },
isDraggableDragging: false,
} as any);
vi.mocked(useWorkflowsStore).mockReturnValue({
getCurrentWorkflow: vi.fn().mockReturnValue({
getParentNodesByDepth: vi.fn().mockReturnValue([]),
}),
getNodeByName: vi.fn().mockReturnValue({}),
} as any);
vi.mocked(usePostHog).mockReturnValue({
isAiEnabled: vi.fn().mockReturnValue(true),
getVariant: vi.fn().mockReturnValue('gpt-3.5-turbo-16k'),
} as any);
vi.mocked(useRootStore).mockReturnValue({
versionCli: '1.0.0',
pushRef: 'testPushRef',
} as any);
vi.mocked(useI18n).mockReturnValue({
baseText: vi.fn().mockReturnValue('Mocked Text'),
nodeText: () => ({
inputLabelDisplayName: vi.fn().mockReturnValue('Mocked Display Name'),
inputLabelDescription: vi.fn().mockReturnValue('Mocked Description'),
}),
} as any);
vi.mocked(useToast).mockReturnValue({
showMessage: vi.fn(),
} as any);
});
const mountComponent = (props: Partial<Props> = {}) => {
return mount(ButtonParameter, {
props: { ...defaultProps, ...props },
global: {
plugins: [createTestingPinia()],
},
});
};
it('renders correctly', () => {
const wrapper = mountComponent();
expect(wrapper.find('textarea').exists()).toBe(true);
expect(wrapper.find('button').text()).toBe('Generate');
});
it('emits valueChanged event on input', async () => {
const wrapper = mountComponent();
const input = wrapper.find('textarea');
await input.setValue('Test prompt');
expect(wrapper.emitted('valueChanged')).toBeTruthy();
expect(wrapper.emitted('valueChanged')![0][0]).toEqual({
name: 'testPath.testParam',
value: 'Test prompt',
});
});
it('disables submit button when there is no execution data', async () => {
vi.mocked(useNDVStore).mockReturnValue({
ndvInputData: [],
} as any);
const wrapper = mountComponent();
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});
it('disables submit button when prompt is empty', async () => {
const wrapper = mountComponent();
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});
it('enables submit button when there is execution data and prompt', async () => {
const wrapper = mountComponent();
await wrapper.find('textarea').setValue('Test prompt');
expect(wrapper.find('button').attributes('disabled')).toBeUndefined();
});
it('calls onSubmit when button is clicked', async () => {
const wrapper = mountComponent();
await wrapper.find('textarea').setValue('Test prompt');
const submitButton = wrapper.find('button');
expect(submitButton.attributes('disabled')).toBeUndefined();
await submitButton.trigger('click');
expect(useToast().showMessage).toHaveBeenCalled();
});
it('disables input and button when in read only mode', async () => {
const wrapper = mountComponent({ isReadOnly: true });
expect(wrapper.find('textarea').attributes('disabled')).toBeDefined();
expect(wrapper.find('button').attributes('disabled')).toBeDefined();
});
});

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import { type INodeProperties, type NodePropertyAction } from 'n8n-workflow';
import type { INodeUi, IUpdateInformation } from '@/Interface';
import { ref, computed, onMounted } from 'vue';
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,
type TextareaRowData,
getUpdatedTextareaValue,
getTextareaCursorPosition,
} from './utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { propertyNameFromExpression } from '../../utils/mappingUtils';
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
const emit = defineEmits<{
valueChanged: [value: IUpdateInformation];
}>();
export type Props = {
parameter: INodeProperties;
value: string;
path: string;
isReadOnly?: boolean;
};
const props = defineProps<Props>();
const { activeNode } = useNDVStore();
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);
const inputFieldMaxLength = computed(
() => props.parameter.typeOptions?.buttonConfig?.inputFieldMaxLength,
);
const buttonLabel = computed(
() => props.parameter.typeOptions?.buttonConfig?.label ?? props.parameter.displayName,
);
const isSubmitEnabled = computed(() => {
if (!hasExecutionData.value || !prompt.value || props.isReadOnly) return false;
const maxlength = inputFieldMaxLength.value;
if (maxlength && prompt.value.length > maxlength) return false;
return true;
});
const promptUpdated = computed(() => {
const lastPrompt = activeNode?.parameters[AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT] as string;
if (!lastPrompt) return false;
return lastPrompt.trim() !== prompt.value.trim();
});
function startLoading() {
isLoading.value = true;
}
function stopLoading() {
setTimeout(() => {
isLoading.value = false;
}, 200);
}
function getPath(parameter: string) {
return (props.path ? `${props.path}.` : '') + parameter;
}
async function onSubmit() {
const { showMessage } = useToast();
const action: string | NodePropertyAction | undefined =
props.parameter.typeOptions?.buttonConfig?.action;
if (!action || !activeNode) return;
if (typeof action === 'string') {
switch (action) {
default:
return;
}
}
emit('valueChanged', {
name: getPath(props.parameter.name),
value: prompt.value,
});
const { type, target } = action;
startLoading();
try {
switch (type) {
case 'askAiCodeGeneration':
const updateInformation = await generateCodeForAiTransform(
prompt.value,
getPath(target as string),
5,
);
if (!updateInformation) return;
//updade code parameter
emit('valueChanged', updateInformation);
//update code generated for prompt parameter
emit('valueChanged', {
name: getPath(AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT),
value: prompt.value,
});
useTelemetry().trackAiTransform('generationFinished', {
prompt: prompt.value,
code: updateInformation.value,
});
break;
default:
return;
}
showMessage({
type: 'success',
title: i18n.baseText('codeNodeEditor.askAi.generationCompleted'),
});
stopLoading();
} catch (error) {
useTelemetry().trackAiTransform('generationFinished', {
prompt: prompt.value,
code: '',
hasError: true,
});
showMessage({
type: 'error',
title: i18n.baseText('codeNodeEditor.askAi.generationFailed'),
message: error.message,
});
stopLoading();
}
}
function onPromptInput(inputValue: string) {
prompt.value = inputValue;
emit('valueChanged', {
name: getPath(props.parameter.name),
value: inputValue,
});
}
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>
<div>
<n8n-input-label
v-if="hasInputField"
:label="i18n.nodeText().inputLabelDisplayName(parameter, path)"
:tooltip-text="i18n.nodeText().inputLabelDescription(parameter, path)"
:bold="false"
size="small"
color="text-dark"
>
</n8n-input-label>
<div
:class="[$style.inputContainer, { [$style.disabled]: isReadOnly }]"
:hidden="!hasInputField"
>
<div :class="$style.meta">
<span
v-if="inputFieldMaxLength"
v-show="prompt.length > 1"
:class="$style.counter"
v-text="`${prompt.length} / ${inputFieldMaxLength}`"
/>
<span
v-if="promptUpdated"
:class="$style['warning-text']"
v-text="'Instructions changed'"
/>
</div>
<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"
:disabled="isReadOnly"
@input="onPromptInput"
@mousemove="updateCursorPositionOnMouseMove($event, activeDrop)"
@mouseleave="cleanTextareaRowsData"
/>
</template>
</DraggableTarget>
</div>
<div :class="$style.controls">
<N8nTooltip :disabled="isSubmitEnabled">
<div>
<N8nButton
:disabled="!isSubmitEnabled"
size="small"
:loading="isLoading"
type="secondary"
@click="onSubmit"
>
{{ buttonLabel }}
</N8nButton>
</div>
<template #content>
<span
v-if="!hasExecutionData"
v-text="i18n.baseText('codeNodeEditor.askAi.noInputData')"
/>
<span
v-else-if="prompt.length === 0"
v-text="i18n.baseText('codeNodeEditor.askAi.noPrompt')"
/>
</template>
</N8nTooltip>
</div>
</div>
</template>
<style module lang="scss">
.input * {
border: 1.5px transparent !important;
}
.input {
border-radius: var(--border-radius-base);
}
.input textarea {
font-size: var(--font-size-2xs);
padding-bottom: var(--spacing-2xl);
font-family: var(--font-family);
resize: none;
margin: 0;
}
.intro {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
padding: var(--spacing-2xs) 0 0;
}
.inputContainer {
position: relative;
}
.meta {
display: flex;
justify-content: space-between;
position: absolute;
padding-bottom: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
bottom: 2px;
left: var(--spacing-xs);
right: var(--spacing-xs);
gap: var(--spacing-2xs);
align-items: end;
z-index: 1;
background-color: var(--color-foreground-xlight);
* {
font-size: var(--font-size-2xs);
line-height: 1;
}
}
.counter {
color: var(--color-text-light);
flex-shrink: 0;
}
.controls {
padding: var(--spacing-2xs) 0;
display: flex;
justify-content: flex-end;
}
.warning-text {
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;
}
.disabled {
.meta {
background-color: var(--fill-disabled);
}
}
</style>

View File

@@ -0,0 +1,156 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateCodeForAiTransform, reducePayloadSizeOrThrow } from './utils';
import { createPinia, setActivePinia } from 'pinia';
import { generateCodeForPrompt } from '@/api/ai';
import type { AskAiRequest } from '@/types/assistant.types';
import type { Schema } from '@/Interface';
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
return {
...actual,
getSchemas: vi.fn(() => ({
parentNodesSchemas: { test: 'parentSchema' },
inputSchema: { test: 'inputSchema' },
})),
};
});
vi.mock('@/stores/root.store', () => ({
useRootStore: () => ({
pushRef: 'mockRootPushRef',
restApiContext: {},
}),
}));
vi.mock('@/stores/ndv.store', () => ({
useNDVStore: () => ({
pushRef: 'mockNdvPushRef',
}),
}));
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
}));
vi.mock('prettier', () => ({
format: vi.fn(async (code) => await Promise.resolve(`formatted-${code}`)),
}));
vi.mock('@/api/ai', () => ({
generateCodeForPrompt: vi.fn(),
}));
describe('generateCodeForAiTransform - Retry Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
const pinia = createPinia();
setActivePinia(pinia);
});
it('should retry and succeed on the second attempt', async () => {
const mockGeneratedCode = 'const example = "retry success";';
vi.mocked(generateCodeForPrompt)
.mockRejectedValueOnce(new Error('First attempt failed'))
.mockResolvedValueOnce({ code: mockGeneratedCode });
const result = await generateCodeForAiTransform('test prompt', 'test/path', 2);
expect(result).toEqual({
name: 'test/path',
value: 'formatted-const example = "retry success";',
});
expect(generateCodeForPrompt).toHaveBeenCalledTimes(2);
});
it('should exhaust retries and throw an error', async () => {
vi.mocked(generateCodeForPrompt).mockRejectedValue(new Error('All attempts failed'));
await expect(generateCodeForAiTransform('test prompt', 'test/path', 3)).rejects.toThrow(
'All attempts failed',
);
expect(generateCodeForPrompt).toHaveBeenCalledTimes(3);
});
it('should succeed on the first attempt without retries', async () => {
const mockGeneratedCode = 'const example = "no retries needed";';
vi.mocked(generateCodeForPrompt).mockResolvedValue({ code: mockGeneratedCode });
const result = await generateCodeForAiTransform('test prompt', 'test/path');
expect(result).toEqual({
name: 'test/path',
value: 'formatted-const example = "no retries needed";',
});
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
});
});
const mockPayload = () =>
({
context: {
schema: [
{ nodeName: 'node1', data: 'some data' },
{ nodeName: 'node2', data: 'other data' },
],
inputSchema: {
schema: {
value: [
{ key: 'prop1', value: 'value1' },
{ key: 'prop2', value: 'value2' },
],
},
},
},
question: 'What is node1 and prop1?',
}) as unknown as AskAiRequest.RequestPayload;
describe('reducePayloadSizeOrThrow', () => {
it('reduces schema size when tokens exceed the limit', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 104 were provided');
reducePayloadSizeOrThrow(payload, error);
expect(payload.context.schema.length).toBe(1);
expect(payload.context.schema[0]).toEqual({ nodeName: 'node1', data: 'some data' });
});
it('removes unreferenced properties in input schema', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 150 were provided');
reducePayloadSizeOrThrow(payload, error);
expect(payload.context.inputSchema.schema.value.length).toBe(1);
expect((payload.context.inputSchema.schema.value as Schema[])[0].key).toBe('prop1');
});
it('removes all parent nodes if needed', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 150 were provided');
payload.question = '';
reducePayloadSizeOrThrow(payload, error);
expect(payload.context.schema.length).toBe(0);
});
it('throws error if tokens still exceed after reductions', () => {
const payload = mockPayload();
const error = new Error('Limit is 100 tokens, but 200 were provided');
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
});
it('throws error if message format is invalid', () => {
const payload = mockPayload();
const error = new Error('Invalid token message format');
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
});
});

View File

@@ -0,0 +1,399 @@
import type { Schema } from '@/Interface';
import { ApplicationError, type INodeExecutionData } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useDataSchema } from '@/composables/useDataSchema';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { generateCodeForPrompt } from '@/api/ai';
import { useRootStore } from '@/stores/root.store';
import { type AskAiRequest } from '@/types/assistant.types';
import { useSettingsStore } from '@/stores/settings.store';
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();
const workflow = getCurrentWorkflow();
if (!activeNode || !workflow) return [];
return workflow
.getParentNodesByDepth(activeNode?.name)
.filter(({ name }, i, nodes) => {
return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i;
})
.map((n) => getNodeByName(n.name))
.filter((n) => n !== null);
}
export function getSchemas() {
const parentNodes = getParentNodes();
const parentNodesNames = parentNodes.map((node) => node?.name);
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes
.map((node) => {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
return {
nodeName: node?.name || '',
schema: getSchemaForExecutionData(executionDataToJson(inputData), false),
};
})
.filter((node) => node.schema?.value.length > 0);
const inputSchema = parentNodesSchemas.shift();
return {
parentNodesNames,
inputSchema,
parentNodesSchemas,
};
}
//------ Reduce payload ------
const estimateNumberOfTokens = (item: unknown, averageTokenLength: number): number => {
if (typeof item === 'object') {
return Math.ceil(JSON.stringify(item).length / averageTokenLength);
}
return 0;
};
const calculateRemainingTokens = (error: Error) => {
// Expected message format:
//'This model's maximum context length is 8192 tokens. However, your messages resulted in 10514 tokens.'
const tokens = error.message.match(/\d+/g);
if (!tokens || tokens.length < 2) throw error;
const maxTokens = parseInt(tokens[0], 10);
const currentTokens = parseInt(tokens[1], 10);
return currentTokens - maxTokens;
};
const trimParentNodesSchema = (
payload: AskAiRequest.RequestPayload,
remainingTokensToReduce: number,
averageTokenLength: number,
) => {
//check if parent nodes schema takes more tokens than available
let parentNodesTokenCount = estimateNumberOfTokens(payload.context.schema, averageTokenLength);
if (remainingTokensToReduce > parentNodesTokenCount) {
remainingTokensToReduce -= parentNodesTokenCount;
payload.context.schema = [];
}
//remove parent nodes not referenced in the prompt
if (payload.context.schema.length) {
const nodes = [...payload.context.schema];
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
if (payload.question.includes(nodes[nodeIndex].nodeName)) continue;
const nodeTokens = estimateNumberOfTokens(nodes[nodeIndex], averageTokenLength);
remainingTokensToReduce -= nodeTokens;
parentNodesTokenCount -= nodeTokens;
payload.context.schema.splice(nodeIndex, 1);
if (remainingTokensToReduce <= 0) break;
}
}
return [remainingTokensToReduce, parentNodesTokenCount];
};
const trimInputSchemaProperties = (
payload: AskAiRequest.RequestPayload,
remainingTokensToReduce: number,
averageTokenLength: number,
parentNodesTokenCount: number,
) => {
if (remainingTokensToReduce <= 0) return remainingTokensToReduce;
//remove properties not referenced in the prompt from the input schema
if (Array.isArray(payload.context.inputSchema.schema.value)) {
const props = [...payload.context.inputSchema.schema.value];
for (let index = 0; index < props.length; index++) {
const key = props[index].key;
if (key && payload.question.includes(key)) continue;
const propTokens = estimateNumberOfTokens(props[index], averageTokenLength);
remainingTokensToReduce -= propTokens;
payload.context.inputSchema.schema.value.splice(index, 1);
if (remainingTokensToReduce <= 0) break;
}
}
//if tokensToReduce is still remaining, remove all parent nodes
if (remainingTokensToReduce > 0) {
payload.context.schema = [];
remainingTokensToReduce -= parentNodesTokenCount;
}
return remainingTokensToReduce;
};
/**
* Attempts to reduce the size of the payload to fit within token limits or throws an error if unsuccessful,
* payload would be modified in place
*
* @param {AskAiRequest.RequestPayload} payload - The request payload to be trimmed,
* 'schema' and 'inputSchema.schema' will be modified.
* @param {Error} error - The error to throw if the token reduction fails.
* @param {number} [averageTokenLength=4] - The average token length used for estimation.
* @throws {Error} - Throws the provided error if the payload cannot be reduced sufficiently.
*/
export function reducePayloadSizeOrThrow(
payload: AskAiRequest.RequestPayload,
error: Error,
averageTokenLength = 4,
) {
let remainingTokensToReduce = calculateRemainingTokens(error);
const [remaining, parentNodesTokenCount] = trimParentNodesSchema(
payload,
remainingTokensToReduce,
averageTokenLength,
);
remainingTokensToReduce = remaining;
remainingTokensToReduce = trimInputSchemaProperties(
payload,
remainingTokensToReduce,
averageTokenLength,
parentNodesTokenCount,
);
if (remainingTokensToReduce > 0) throw error;
}
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
const schemas = getSchemas();
const payload: AskAiRequest.RequestPayload = {
question: prompt,
context: {
schema: schemas.parentNodesSchemas,
inputSchema: schemas.inputSchema!,
ndvPushRef: useNDVStore().pushRef,
pushRef: useRootStore().pushRef,
},
forNode: 'transform',
};
let value;
if (useSettingsStore().isAskAiEnabled) {
const { restApiContext } = useRootStore();
let code = '';
while (retries > 0) {
try {
const { code: generatedCode } = await generateCodeForPrompt(restApiContext, payload);
code = generatedCode;
break;
} catch (e) {
if (e.message.includes('maximum context length')) {
reducePayloadSizeOrThrow(payload, e);
continue;
}
retries--;
if (!retries) throw e;
}
}
value = code;
} else {
throw new ApplicationError('AI code generation is not enabled');
}
if (value === undefined) return;
const formattedCode = await format(String(value), {
parser: 'babel',
plugins: [jsParser, estree],
});
const updateInformation = {
name: path,
value: formattedCode,
};
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;
}