mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 11:01:15 +00:00
feat(editor): Show expression infobox on hover and cursor position (#9507)
Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
@@ -73,7 +73,6 @@ function useJsonFieldCompletions() {
|
||||
* - Complete `$input.item.json[` to `['field']`.
|
||||
*/
|
||||
const inputJsonFieldCompletions = (context: CompletionContext): CompletionResult | null => {
|
||||
console.log('🚀 ~ inputJsonFieldCompletions ~ context:', context);
|
||||
const patterns = {
|
||||
first: /\$input\.first\(\)\.json(\[|\.).*/,
|
||||
last: /\$input\.last\(\)\.json(\[|\.).*/,
|
||||
@@ -158,7 +157,6 @@ function useJsonFieldCompletions() {
|
||||
|
||||
if (name === 'all') {
|
||||
const regexMatch = preCursor.text.match(regex);
|
||||
console.log('🚀 ~ selectorJsonFieldCompletions ~ regexMatch:', regexMatch);
|
||||
if (!regexMatch?.groups?.index) continue;
|
||||
|
||||
const { index } = regexMatch.groups;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@/plugins/codemirror/keymap';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
@@ -68,6 +69,7 @@ const extensions = computed(() => [
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
infoBoxTooltips(),
|
||||
]);
|
||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||
const {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { removeExpressionPrefix } from '@/utils/expressions';
|
||||
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { inputTheme } from './theme';
|
||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||
|
||||
type Props = {
|
||||
modelValue: string;
|
||||
@@ -56,6 +57,7 @@ const extensions = computed(() => [
|
||||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
infoBoxTooltips(),
|
||||
]);
|
||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||
const {
|
||||
@@ -138,7 +140,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" :class="$style.editor" data-test-id="inline-expression-editor-input"></div>
|
||||
<div
|
||||
ref="root"
|
||||
title=""
|
||||
:class="$style.editor"
|
||||
data-test-id="inline-expression-editor-input"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
computed,
|
||||
type MaybeRefOrGetter,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watchEffect,
|
||||
type Ref,
|
||||
toValue,
|
||||
watch,
|
||||
onMounted,
|
||||
watchEffect,
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
} from 'vue';
|
||||
|
||||
import { ensureSyntaxTree } from '@codemirror/language';
|
||||
@@ -19,6 +19,8 @@ import { useNDVStore } from '@/stores/ndv.store';
|
||||
|
||||
import type { TargetItem } from '@/Interface';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
|
||||
import {
|
||||
getExpressionErrorMessage,
|
||||
@@ -28,16 +30,15 @@ import {
|
||||
import { closeCompletion, completionStatus } from '@codemirror/autocomplete';
|
||||
import {
|
||||
Compartment,
|
||||
EditorState,
|
||||
type SelectionRange,
|
||||
type Extension,
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
type Extension,
|
||||
type SelectionRange,
|
||||
} from '@codemirror/state';
|
||||
import { EditorView, type ViewUpdate } from '@codemirror/view';
|
||||
import { debounce, isEqual } from 'lodash-es';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from '../composables/useI18n';
|
||||
import { highlighter } from '../plugins/codemirror/resolvableHighlighter';
|
||||
import { useWorkflowsStore } from '../stores/workflows.store';
|
||||
import { useAutocompleteTelemetry } from './useAutocompleteTelemetry';
|
||||
|
||||
@@ -163,6 +164,7 @@ export const useExpressionEditor = ({
|
||||
if (editor.value) {
|
||||
editor.value.contentDOM.blur();
|
||||
closeCompletion(editor.value);
|
||||
closeCursorInfoBox(editor.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -735,8 +735,8 @@ describe('Resolution-based completions', () => {
|
||||
const result = completions('{{ $json.obj.| }}');
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'array' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'object' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
|
||||
});
|
||||
|
||||
test('should display type information for: {{ $input.item.json.| }}', () => {
|
||||
@@ -750,8 +750,8 @@ describe('Resolution-based completions', () => {
|
||||
const result = completions('{{ $json.item.json.| }}');
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'array' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'object' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
|
||||
});
|
||||
|
||||
test('should display type information for: {{ $("My Node").item.json.| }}', () => {
|
||||
@@ -765,8 +765,8 @@ describe('Resolution-based completions', () => {
|
||||
const result = completions('{{ $("My Node").item.json.| }}');
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'str', detail: 'string' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'empty', detail: 'null' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'array' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'object' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'arr', detail: 'Array' }));
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: 'obj', detail: 'Object' }));
|
||||
});
|
||||
|
||||
test('should not display type information for other completions', () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
|
||||
section: RECOMMENDED_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$json',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.json'),
|
||||
docURL: 'https://docs.n8n.io/data/data-structure/',
|
||||
}),
|
||||
@@ -64,7 +64,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
|
||||
section: RECOMMENDED_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$binary',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.binary'),
|
||||
}),
|
||||
},
|
||||
@@ -170,7 +170,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
|
||||
section: METADATA_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$input',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$input'),
|
||||
}),
|
||||
},
|
||||
@@ -179,7 +179,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
|
||||
section: METADATA_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$parameter',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$parameter'),
|
||||
}),
|
||||
},
|
||||
@@ -215,7 +215,7 @@ export const ROOT_DOLLAR_COMPLETIONS: Completion[] = [
|
||||
section: METADATA_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$vars',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$vars'),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -35,10 +35,10 @@ import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs
|
||||
import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs';
|
||||
import type { AutocompleteInput, ExtensionTypeName, FnToDoc, Resolved } from './types';
|
||||
import {
|
||||
applyBracketAccess,
|
||||
applyBracketAccessCompletion,
|
||||
applyCompletion,
|
||||
getDefaultArgs,
|
||||
getDisplayType,
|
||||
hasNoParams,
|
||||
hasRequiredArgs,
|
||||
insertDefaultArgs,
|
||||
@@ -181,7 +181,7 @@ export const natives = ({
|
||||
typeName: ExtensionTypeName;
|
||||
transformLabel?: (label: string) => string;
|
||||
}): Completion[] => {
|
||||
const nativeDocs: NativeDoc = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
|
||||
const nativeDocs = NativeMethods.find((ee) => ee.typeName.toLowerCase() === typeName);
|
||||
|
||||
if (!nativeDocs) return [];
|
||||
|
||||
@@ -231,12 +231,6 @@ export const extensions = ({
|
||||
return toOptions({ fnToDoc, isFunction: true, includeHidden, transformLabel });
|
||||
};
|
||||
|
||||
export const getType = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return 'array';
|
||||
if (value === null) return 'null';
|
||||
return (typeof value).toLocaleLowerCase();
|
||||
};
|
||||
|
||||
export const isInputData = (base: string): boolean => {
|
||||
return (
|
||||
/^\$input\..*\.json]/.test(base) || /^\$json/.test(base) || /^\$\(.*\)\..*\.json/.test(base)
|
||||
@@ -258,7 +252,7 @@ export const isBinary = (input: AutocompleteInput<IDataObject>): boolean => {
|
||||
};
|
||||
|
||||
export const getDetail = (base: string, value: unknown): string | undefined => {
|
||||
const type = getType(value);
|
||||
const type = getDisplayType(value);
|
||||
if (!isInputData(base) || type === 'function') return undefined;
|
||||
return type;
|
||||
};
|
||||
@@ -382,17 +376,6 @@ const objectOptions = (input: AutocompleteInput<IDataObject>): Completion[] => {
|
||||
detail: getDetail(base, resolvedProp),
|
||||
};
|
||||
|
||||
const infoName = needsBracketAccess ? applyBracketAccess(key) : key;
|
||||
option.info = createCompletionOption({
|
||||
name: infoName,
|
||||
doc: {
|
||||
name: infoName,
|
||||
returnType: isFunction ? 'any' : getType(resolvedProp),
|
||||
},
|
||||
isFunction,
|
||||
transformLabel,
|
||||
}).info;
|
||||
|
||||
return option;
|
||||
});
|
||||
|
||||
@@ -821,7 +804,7 @@ export const customDataOptions = () => {
|
||||
},
|
||||
{
|
||||
name: 'getAll',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
docURL: 'https://docs.n8n.io/workflows/executions/custom-executions-data/',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$execution.customData.getAll'),
|
||||
examples: [
|
||||
@@ -1046,13 +1029,13 @@ export const itemOptions = () => {
|
||||
return [
|
||||
{
|
||||
name: 'json',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
docURL: 'https://docs.n8n.io/data/data-structure/',
|
||||
description: i18n.baseText('codeNodeEditor.completer.item.json'),
|
||||
},
|
||||
{
|
||||
name: 'binary',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
docURL: 'https://docs.n8n.io/data/data-structure/',
|
||||
description: i18n.baseText('codeNodeEditor.completer.item.binary'),
|
||||
},
|
||||
@@ -1161,7 +1144,7 @@ export const secretProvidersOptions = () => {
|
||||
name: provider,
|
||||
doc: {
|
||||
name: provider,
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$secrets.provider'),
|
||||
docURL: i18n.baseText('settings.externalSecrets.docs'),
|
||||
},
|
||||
@@ -1296,7 +1279,7 @@ export const objectGlobalOptions = () => {
|
||||
evaluated: "{ id: 1, name: 'Banana' }",
|
||||
},
|
||||
],
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
docURL:
|
||||
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign',
|
||||
},
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||
import { escapeMappingString } from '@/utils/mappingUtils';
|
||||
import { PREVIOUS_NODES_SECTION, RECOMMENDED_SECTION, ROOT_DOLLAR_COMPLETIONS } from './constants';
|
||||
import {
|
||||
METADATA_SECTION,
|
||||
PREVIOUS_NODES_SECTION,
|
||||
RECOMMENDED_SECTION,
|
||||
ROOT_DOLLAR_COMPLETIONS,
|
||||
} from './constants';
|
||||
import { createInfoBoxRenderer } from './infoBoxRenderer';
|
||||
|
||||
/**
|
||||
@@ -79,7 +84,7 @@ export function dollarOptions(): Completion[] {
|
||||
section: RECOMMENDED_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$request',
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
docURL: 'https://docs.n8n.io/code/builtin/http-node-variables/',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$request'),
|
||||
}),
|
||||
@@ -91,12 +96,22 @@ export function dollarOptions(): Completion[] {
|
||||
return useExternalSecretsStore().isEnterpriseExternalSecretsEnabled
|
||||
? [
|
||||
{
|
||||
label: '$secrets',
|
||||
type: 'keyword',
|
||||
label: '$vars',
|
||||
section: METADATA_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$vars',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$vars'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: '$vars',
|
||||
type: 'keyword',
|
||||
label: '$secrets',
|
||||
section: METADATA_SECTION,
|
||||
info: createInfoBoxRenderer({
|
||||
name: '$secrets',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$secrets'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -114,7 +129,7 @@ export function dollarOptions(): Completion[] {
|
||||
label,
|
||||
info: createInfoBoxRenderer({
|
||||
name: label,
|
||||
returnType: 'object',
|
||||
returnType: 'Object',
|
||||
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
|
||||
}),
|
||||
section: PREVIOUS_NODES_SECTION,
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { Completion } from '@codemirror/autocomplete';
|
||||
import type { DocMetadata, DocMetadataArgument, DocMetadataExample } from 'n8n-workflow';
|
||||
import { sanitizeHtml } from '@/utils/htmlUtils';
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
|
||||
const renderFunctionHeader = (doc?: DocMetadata) => {
|
||||
const shouldHighlightArgument = (
|
||||
arg: DocMetadataArgument,
|
||||
index: number,
|
||||
highlightArgIndex?: number,
|
||||
) => {
|
||||
if (arg.variadic) {
|
||||
return (highlightArgIndex ?? 0) >= index;
|
||||
}
|
||||
|
||||
return highlightArgIndex === index;
|
||||
};
|
||||
|
||||
const renderFunctionHeader = (doc?: DocMetadata, highlightArgIndex?: number) => {
|
||||
const header = document.createElement('div');
|
||||
if (doc) {
|
||||
const functionNameSpan = document.createElement('span');
|
||||
@@ -17,7 +30,10 @@ const renderFunctionHeader = (doc?: DocMetadata) => {
|
||||
const argsSpan = document.createElement('span');
|
||||
doc.args?.forEach((arg, index, array) => {
|
||||
const optional = arg.optional && !arg.name.endsWith('?');
|
||||
const argSpan = document.createElement('span');
|
||||
const argSpan = document.createElement(
|
||||
shouldHighlightArgument(arg, index, highlightArgIndex) ? 'strong' : 'span',
|
||||
);
|
||||
argSpan.classList.add('autocomplete-info-arg');
|
||||
argSpan.textContent = arg.name;
|
||||
|
||||
if (optional) {
|
||||
@@ -28,27 +44,19 @@ const renderFunctionHeader = (doc?: DocMetadata) => {
|
||||
argSpan.textContent = '...' + argSpan.textContent;
|
||||
}
|
||||
|
||||
argSpan.classList.add('autocomplete-info-arg');
|
||||
argsSpan.appendChild(argSpan);
|
||||
|
||||
if (index !== array.length - 1) {
|
||||
const separatorSpan = document.createElement('span');
|
||||
separatorSpan.textContent = ', ';
|
||||
argsSpan.appendChild(separatorSpan);
|
||||
} else {
|
||||
argSpan.textContent += ')';
|
||||
}
|
||||
});
|
||||
header.appendChild(argsSpan);
|
||||
|
||||
const preTypeInfo = document.createElement('span');
|
||||
preTypeInfo.textContent = !doc.args || doc.args.length === 0 ? '): ' : ': ';
|
||||
header.appendChild(preTypeInfo);
|
||||
|
||||
const returnTypeSpan = document.createElement('span');
|
||||
returnTypeSpan.textContent = doc.returnType;
|
||||
returnTypeSpan.classList.add('autocomplete-info-return');
|
||||
header.appendChild(returnTypeSpan);
|
||||
const closingBracket = document.createElement('span');
|
||||
closingBracket.textContent = ')';
|
||||
header.appendChild(closingBracket);
|
||||
}
|
||||
return header;
|
||||
};
|
||||
@@ -58,13 +66,9 @@ const renderPropHeader = (doc?: DocMetadata) => {
|
||||
if (doc) {
|
||||
const propNameSpan = document.createElement('span');
|
||||
propNameSpan.classList.add('autocomplete-info-name');
|
||||
propNameSpan.innerText = doc.name;
|
||||
|
||||
const returnTypeSpan = document.createElement('span');
|
||||
returnTypeSpan.textContent = ': ' + doc.returnType;
|
||||
propNameSpan.textContent = doc.name;
|
||||
|
||||
header.appendChild(propNameSpan);
|
||||
header.appendChild(returnTypeSpan);
|
||||
}
|
||||
return header;
|
||||
};
|
||||
@@ -110,9 +114,9 @@ const renderDescription = ({
|
||||
return descriptionBody;
|
||||
};
|
||||
|
||||
const renderArg = (arg: DocMetadataArgument) => {
|
||||
const renderArg = (arg: DocMetadataArgument, highlight: boolean) => {
|
||||
const argItem = document.createElement('li');
|
||||
const argName = document.createElement('span');
|
||||
const argName = document.createElement(highlight ? 'strong' : 'span');
|
||||
argName.classList.add('autocomplete-info-arg-name');
|
||||
argName.textContent = arg.name.replaceAll('?', '');
|
||||
const tags = [];
|
||||
@@ -159,18 +163,18 @@ const renderArg = (arg: DocMetadataArgument) => {
|
||||
return argItem;
|
||||
};
|
||||
|
||||
const renderArgList = (args: DocMetadataArgument[]) => {
|
||||
const renderArgList = (args: DocMetadataArgument[], highlightArgIndex?: number) => {
|
||||
const argsList = document.createElement('ul');
|
||||
argsList.classList.add('autocomplete-info-args');
|
||||
|
||||
for (const arg of args) {
|
||||
argsList.appendChild(renderArg(arg));
|
||||
}
|
||||
args.forEach((arg, index) => {
|
||||
argsList.appendChild(renderArg(arg, shouldHighlightArgument(arg, index, highlightArgIndex)));
|
||||
});
|
||||
|
||||
return argsList;
|
||||
};
|
||||
|
||||
const renderArgs = (args: DocMetadataArgument[]) => {
|
||||
const renderArgs = (args: DocMetadataArgument[], highlightArgIndex?: number) => {
|
||||
const argsContainer = document.createElement('div');
|
||||
argsContainer.classList.add('autocomplete-info-args-container');
|
||||
|
||||
@@ -178,7 +182,7 @@ const renderArgs = (args: DocMetadataArgument[]) => {
|
||||
argsTitle.classList.add('autocomplete-info-section-title');
|
||||
argsTitle.textContent = i18n.baseText('codeNodeEditor.parameters');
|
||||
argsContainer.appendChild(argsTitle);
|
||||
argsContainer.appendChild(renderArgList(args));
|
||||
argsContainer.appendChild(renderArgList(args, highlightArgIndex));
|
||||
return argsContainer;
|
||||
};
|
||||
|
||||
@@ -233,7 +237,7 @@ const renderExamples = (examples: DocMetadataExample[]) => {
|
||||
|
||||
export const createInfoBoxRenderer =
|
||||
(doc?: DocMetadata, isFunction = false) =>
|
||||
() => {
|
||||
(_completion: Completion, highlightArgIndex = -1) => {
|
||||
const tooltipContainer = document.createElement('div');
|
||||
tooltipContainer.setAttribute('tabindex', '-1');
|
||||
tooltipContainer.setAttribute('title', '');
|
||||
@@ -245,7 +249,9 @@ export const createInfoBoxRenderer =
|
||||
const hasArgs = args && args.length > 0;
|
||||
const hasExamples = examples && examples.length > 0;
|
||||
|
||||
const header = isFunction ? renderFunctionHeader(doc) : renderPropHeader(doc);
|
||||
const header = isFunction
|
||||
? renderFunctionHeader(doc, highlightArgIndex)
|
||||
: renderPropHeader(doc);
|
||||
header.classList.add('autocomplete-info-header');
|
||||
tooltipContainer.appendChild(header);
|
||||
|
||||
@@ -259,7 +265,7 @@ export const createInfoBoxRenderer =
|
||||
}
|
||||
|
||||
if (hasArgs) {
|
||||
const argsContainer = renderArgs(args);
|
||||
const argsContainer = renderArgs(args, highlightArgIndex);
|
||||
tooltipContainer.appendChild(argsContainer);
|
||||
}
|
||||
|
||||
|
||||
@@ -258,3 +258,15 @@ export const isCompletionSection = (
|
||||
): section is CompletionSection => {
|
||||
return typeof section === 'object';
|
||||
};
|
||||
|
||||
export const getDisplayType = (value: unknown): string => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
return `${getDisplayType(value[0])}[]`;
|
||||
}
|
||||
return 'Array';
|
||||
}
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'object') return 'Object';
|
||||
return (typeof value).toLocaleLowerCase();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
CompletionContext,
|
||||
completionStatus,
|
||||
type Completion,
|
||||
type CompletionInfo,
|
||||
type CompletionResult,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { StateEffect, StateField, type EditorState, type Extension } from '@codemirror/state';
|
||||
import {
|
||||
hoverTooltip,
|
||||
keymap,
|
||||
showTooltip,
|
||||
type Command,
|
||||
type EditorView,
|
||||
type Tooltip,
|
||||
} from '@codemirror/view';
|
||||
import type { SyntaxNode } from '@lezer/common';
|
||||
import type { createInfoBoxRenderer } from '../completions/infoBoxRenderer';
|
||||
|
||||
const findNearestParentOfType =
|
||||
(type: string) =>
|
||||
(node: SyntaxNode): SyntaxNode | null => {
|
||||
if (node.name === type) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.parent) {
|
||||
return findNearestParentOfType(type)(node.parent);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findNearestArgList = findNearestParentOfType('ArgList');
|
||||
const findNearestCallExpression = findNearestParentOfType('CallExpression');
|
||||
|
||||
function completionToTooltip(
|
||||
completion: Completion | null,
|
||||
pos: number,
|
||||
options: { argIndex?: number; end?: number } = {},
|
||||
): Tooltip | null {
|
||||
if (!completion) return null;
|
||||
|
||||
return {
|
||||
pos,
|
||||
end: options.end,
|
||||
above: true,
|
||||
create: () => {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('cm-cursorInfo');
|
||||
const info = completion.info;
|
||||
if (typeof info === 'string') {
|
||||
element.textContent = info;
|
||||
} else if (isInfoBoxRenderer(info)) {
|
||||
const infoResult = info(completion, options.argIndex ?? -1);
|
||||
|
||||
if (infoResult) {
|
||||
element.appendChild(infoResult);
|
||||
}
|
||||
}
|
||||
|
||||
return { dom: element };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findActiveArgIndex(node: SyntaxNode, index: number) {
|
||||
let currentIndex = 1;
|
||||
let argIndex = 0;
|
||||
let child: SyntaxNode | null = null;
|
||||
|
||||
do {
|
||||
child = node.childAfter(currentIndex);
|
||||
|
||||
if (child) {
|
||||
currentIndex = child.to;
|
||||
|
||||
if (index >= child.from && index <= child.to) {
|
||||
return argIndex;
|
||||
}
|
||||
|
||||
if (child.name !== ',' && child.name !== '(') argIndex++;
|
||||
}
|
||||
} while (child);
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
const createStateReader = (state: EditorState) => (node?: SyntaxNode | null) => {
|
||||
return node ? state.sliceDoc(node.from, node.to) : '';
|
||||
};
|
||||
|
||||
const createStringReader = (str: string) => (node?: SyntaxNode | null) => {
|
||||
return node ? str.slice(node.from, node.to) : '';
|
||||
};
|
||||
|
||||
function getJsNodeAtPosition(state: EditorState, pos: number, anchor?: number) {
|
||||
// Syntax node in the n8n language (Resolvable | Plaintext)
|
||||
const rootNode = syntaxTree(state).resolveInner(pos, -1);
|
||||
|
||||
if (rootNode.name !== 'Resolvable') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const read = createStateReader(state);
|
||||
const resolvable = read(rootNode);
|
||||
const jsCode = resolvable.replace(/^{{\s*(.*)\s*}}$/, '$1');
|
||||
const prefixLength = resolvable.indexOf(jsCode);
|
||||
const jsOffset = rootNode.from + prefixLength;
|
||||
const jsPos = pos - jsOffset;
|
||||
const jsAnchor = anchor ? anchor - jsOffset : jsPos;
|
||||
const getGlobalPosition = (jsPosition: number) => jsPosition + jsOffset;
|
||||
const isSelectionWithinNode = (n: SyntaxNode) => {
|
||||
return jsPos >= n.from && jsPos <= n.to && jsAnchor >= n.from && jsAnchor <= n.to;
|
||||
};
|
||||
|
||||
// Cursor or selection is outside of JS code
|
||||
if (jsPos >= jsCode.length || jsAnchor >= jsCode.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Syntax node in JavaScript
|
||||
const jsNode = javascriptLanguage.parser
|
||||
.parse(jsCode)
|
||||
.resolveInner(jsPos, typeof anchor === 'number' ? 0 : -1);
|
||||
|
||||
return {
|
||||
node: jsNode,
|
||||
pos: jsPos,
|
||||
readNode: createStringReader(jsCode),
|
||||
isSelectionWithinNode,
|
||||
getGlobalPosition,
|
||||
};
|
||||
}
|
||||
|
||||
function getCompletion(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
filter: (completion: Completion) => boolean,
|
||||
): Completion | null {
|
||||
const context = new CompletionContext(state, pos, true);
|
||||
const sources = state.languageDataAt<(context: CompletionContext) => CompletionResult>(
|
||||
'autocomplete',
|
||||
pos,
|
||||
);
|
||||
|
||||
for (const source of sources) {
|
||||
const result = source(context);
|
||||
|
||||
const options = result?.options.filter(filter);
|
||||
if (options && options.length > 0) {
|
||||
return options[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const isInfoBoxRenderer = (
|
||||
info: string | ((completion: Completion) => CompletionInfo | Promise<CompletionInfo>) | undefined,
|
||||
): info is ReturnType<typeof createInfoBoxRenderer> => {
|
||||
return typeof info === 'function';
|
||||
};
|
||||
|
||||
function getInfoBoxTooltip(state: EditorState): Tooltip | null {
|
||||
const { head, anchor } = state.selection.ranges[0];
|
||||
const jsNodeResult = getJsNodeAtPosition(state, head, anchor);
|
||||
|
||||
if (!jsNodeResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { node, pos, isSelectionWithinNode, getGlobalPosition, readNode } = jsNodeResult;
|
||||
|
||||
const argList = findNearestArgList(node);
|
||||
if (!argList || !isSelectionWithinNode(argList)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const callExpression = findNearestCallExpression(argList);
|
||||
if (!callExpression) {
|
||||
return null;
|
||||
}
|
||||
const argIndex = findActiveArgIndex(argList, pos);
|
||||
const subject = callExpression?.firstChild;
|
||||
|
||||
switch (subject?.name) {
|
||||
case 'MemberExpression': {
|
||||
const methodName = readNode(subject.lastChild);
|
||||
const completion = getCompletion(
|
||||
state,
|
||||
getGlobalPosition(subject.to - 1),
|
||||
(c) => c.label === methodName + '()',
|
||||
);
|
||||
|
||||
return completionToTooltip(completion, head, { argIndex });
|
||||
}
|
||||
case 'VariableName': {
|
||||
const methodName = readNode(subject);
|
||||
const completion = getCompletion(
|
||||
state,
|
||||
getGlobalPosition(subject.to - 1),
|
||||
(c) => c.label === methodName + '()',
|
||||
);
|
||||
|
||||
return completionToTooltip(completion, head, { argIndex });
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const cursorInfoBoxTooltip = StateField.define<{ tooltip: Tooltip | null }>({
|
||||
create(state) {
|
||||
return { tooltip: getInfoBoxTooltip(state) };
|
||||
},
|
||||
|
||||
update(value, tr) {
|
||||
if (
|
||||
tr.state.selection.ranges.length !== 1 ||
|
||||
tr.state.selection.ranges[0].head === 0 ||
|
||||
completionStatus(tr.state) === 'active'
|
||||
) {
|
||||
return { tooltip: null };
|
||||
}
|
||||
|
||||
if (tr.effects.find((effect) => effect.is(closeInfoBoxEffect))) {
|
||||
return { tooltip: null };
|
||||
}
|
||||
|
||||
if (!tr.docChanged && !tr.selection) return { tooltip: value.tooltip };
|
||||
|
||||
return { ...value, tooltip: getInfoBoxTooltip(tr.state) };
|
||||
},
|
||||
|
||||
provide: (f) => showTooltip.compute([f], (state) => state.field(f).tooltip),
|
||||
});
|
||||
|
||||
export const hoverTooltipSource = (view: EditorView, pos: number) => {
|
||||
const state = view.state.field(cursorInfoBoxTooltip, false);
|
||||
const cursorTooltipOpen = !!state?.tooltip;
|
||||
|
||||
const jsNodeResult = getJsNodeAtPosition(view.state, pos);
|
||||
|
||||
if (!jsNodeResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { node, getGlobalPosition, readNode } = jsNodeResult;
|
||||
|
||||
const tooltipForNode = (subject: SyntaxNode) => {
|
||||
const completion = getCompletion(
|
||||
view.state,
|
||||
getGlobalPosition(subject.to - 1),
|
||||
(c) => c.label === readNode(subject) || c.label === readNode(subject) + '()',
|
||||
);
|
||||
|
||||
const newHoverTooltip = completionToTooltip(completion, getGlobalPosition(subject.from), {
|
||||
end: getGlobalPosition(subject.to),
|
||||
});
|
||||
|
||||
if (newHoverTooltip && cursorTooltipOpen) {
|
||||
closeCursorInfoBox(view);
|
||||
}
|
||||
|
||||
return newHoverTooltip;
|
||||
};
|
||||
|
||||
switch (node.name) {
|
||||
case 'VariableName':
|
||||
case 'PropertyName': {
|
||||
return tooltipForNode(node);
|
||||
}
|
||||
case 'String':
|
||||
case 'Number':
|
||||
case 'Boolean':
|
||||
case 'CallExpression': {
|
||||
const callExpression = findNearestCallExpression(node);
|
||||
|
||||
if (!callExpression) return null;
|
||||
|
||||
return tooltipForNode(callExpression);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const hoverInfoBoxTooltip = hoverTooltip(hoverTooltipSource, {
|
||||
hideOnChange: true,
|
||||
hoverTime: 500,
|
||||
});
|
||||
|
||||
const closeInfoBoxEffect = StateEffect.define<null>();
|
||||
|
||||
export const closeCursorInfoBox: Command = (view) => {
|
||||
const state = view.state.field(cursorInfoBoxTooltip, false);
|
||||
if (!state?.tooltip) return false;
|
||||
|
||||
view.dispatch({ effects: closeInfoBoxEffect.of(null) });
|
||||
return true;
|
||||
};
|
||||
|
||||
export const infoBoxTooltips = (): Extension[] => {
|
||||
return [
|
||||
cursorInfoBoxTooltip,
|
||||
hoverInfoBoxTooltip,
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Escape',
|
||||
run: closeCursorInfoBox,
|
||||
},
|
||||
]),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, getTooltip, showTooltip, type Tooltip } from '@codemirror/view';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { hoverTooltipSource, infoBoxTooltips } from '../InfoBoxTooltip';
|
||||
import * as utils from '@/plugins/codemirror/completions/utils';
|
||||
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
|
||||
|
||||
describe('Infobox tooltips', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
vi.spyOn(utils, 'hasActiveNode').mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('Cursor tooltips', () => {
|
||||
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', () => {
|
||||
const tooltips = cursorTooltips('{{ $max(1,2) }} foo|');
|
||||
expect(tooltips.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should NOT show a tooltip for: {{ $ma|x() }}', () => {
|
||||
const tooltips = cursorTooltips('{{ $ma|x() }}');
|
||||
expect(tooltips.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $max(|) }}', () => {
|
||||
const tooltips = cursorTooltips('{{ $max(|) }}');
|
||||
expect(tooltips.length).toBe(1);
|
||||
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('$max(...numbers)');
|
||||
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $max(1,2,3,|) }}', () => {
|
||||
const tooltips = cursorTooltips('{{ $max(1, 2|) }}');
|
||||
expect(tooltips.length).toBe(1);
|
||||
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('$max(...numbers)');
|
||||
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
|
||||
});
|
||||
|
||||
test('should NOT show a tooltip for: {{ $json.str|.includes("test") }}', () => {
|
||||
const tooltips = cursorTooltips('{{ $json.str|.includes("test") }}');
|
||||
expect(tooltips.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $json.str.includes(|) }}', () => {
|
||||
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
|
||||
const tooltips = cursorTooltips('{{ $json.str.includes(|) }}');
|
||||
expect(tooltips.length).toBe(1);
|
||||
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
|
||||
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $json.str.includes("tes|t") }}', () => {
|
||||
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
|
||||
const tooltips = cursorTooltips('{{ $json.str.includes("tes|t") }}');
|
||||
expect(tooltips.length).toBe(1);
|
||||
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
|
||||
expect(highlightedArgIndex(tooltips[0].view)).toBe(0);
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $json.str.includes("test",|) }}', () => {
|
||||
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('a string');
|
||||
const tooltips = cursorTooltips('{{ $json.str.includes("test",|) }}');
|
||||
expect(tooltips.length).toBe(1);
|
||||
expect(infoBoxHeader(tooltips[0].view)).toHaveTextContent('includes(searchString, start?)');
|
||||
expect(highlightedArgIndex(tooltips[0].view)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hover tooltips', () => {
|
||||
test('should NOT show a tooltip for: {{ $max(1,2) }} foo|', () => {
|
||||
const tooltip = hoverTooltip('{{ $max(1,2) }} foo|');
|
||||
expect(tooltip).toBeNull();
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $jso|n }}', () => {
|
||||
const tooltip = hoverTooltip('{{ $jso|n }}');
|
||||
expect(tooltip).not.toBeNull();
|
||||
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('$json');
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $execution.mo|de }}', () => {
|
||||
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue({ mode: 'foo' });
|
||||
const tooltip = hoverTooltip('{{ $execution.mo|de }}');
|
||||
expect(tooltip).not.toBeNull();
|
||||
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('mode');
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $jmespa|th() }}', () => {
|
||||
const tooltip = hoverTooltip('{{ $jmespa|th() }}');
|
||||
expect(tooltip).not.toBeNull();
|
||||
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('$jmespath(obj, expression)');
|
||||
});
|
||||
|
||||
test('should show a tooltip for: {{ $json.str.includ|es() }}', () => {
|
||||
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValue('foo');
|
||||
const tooltip = hoverTooltip('{{ $json.str.includ|es() }}');
|
||||
expect(tooltip).not.toBeNull();
|
||||
expect(infoBoxHeader(tooltip?.view)).toHaveTextContent('includes(searchString, start?)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function highlightedArgIndex(infoBox: HTMLElement | undefined) {
|
||||
return Array.from(infoBox?.querySelectorAll('.autocomplete-info-arg-name') ?? []).findIndex(
|
||||
(arg) => arg.localName === 'strong',
|
||||
);
|
||||
}
|
||||
|
||||
function infoBoxHeader(infoBox: HTMLElement | undefined) {
|
||||
return infoBox?.querySelector('.autocomplete-info-header');
|
||||
}
|
||||
|
||||
function cursorTooltips(docWithCursor: string) {
|
||||
const cursorPosition = docWithCursor.indexOf('|');
|
||||
|
||||
const doc = docWithCursor.slice(0, cursorPosition) + docWithCursor.slice(cursorPosition + 1);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
selection: { anchor: cursorPosition },
|
||||
extensions: [n8nLang(), infoBoxTooltips()],
|
||||
});
|
||||
const view = new EditorView({ parent: document.createElement('div'), state });
|
||||
|
||||
return state
|
||||
.facet(showTooltip)
|
||||
.filter((t): t is Tooltip => !!t)
|
||||
.map((tooltip) => ({ tooltip, view: getTooltip(view, tooltip)?.dom }));
|
||||
}
|
||||
|
||||
function hoverTooltip(docWithCursor: string) {
|
||||
const hoverPosition = docWithCursor.indexOf('|');
|
||||
|
||||
const doc = docWithCursor.slice(0, hoverPosition) + docWithCursor.slice(hoverPosition + 1);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [n8nLang(), infoBoxTooltips()],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: document.createElement('div') });
|
||||
|
||||
const tooltip = hoverTooltipSource(view, hoverPosition);
|
||||
|
||||
if (!tooltip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { tooltip, view: tooltip.create(view).dom };
|
||||
}
|
||||
@@ -243,7 +243,7 @@
|
||||
"codeNodeEditor.completer.$today": "A DateTime representing midnight at the start of the current day. \n\nUses the instance's time zone (unless overridden in the workflow's settings).",
|
||||
"codeNodeEditor.completer.$vars": "The <a target=\"_blank\" href=\"https://docs.n8n.io/code/variables/\">variables</a> available to the workflow",
|
||||
"codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.",
|
||||
"codeNodeEditor.completer.$secrets": "The external secrets connected to your instance",
|
||||
"codeNodeEditor.completer.$secrets": "The secrets from an <a target=\"_blank\" href=\"https://docs.n8n.io/external-secrets/\">external secrets vault</a>, if configured. Secret values are never displayed to the user. Only available in credential fields.",
|
||||
"codeNodeEditor.completer.$secrets.provider": "External secrets providers connected to this n8n instance.",
|
||||
"codeNodeEditor.completer.$secrets.provider.varName": "External secrets connected to this n8n instance. All secrets evaluate to strings.",
|
||||
"codeNodeEditor.completer.$workflow": "Information about the current workflow",
|
||||
|
||||
@@ -94,39 +94,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete-info-container {
|
||||
.ͼ2 .cm-tooltip.cm-completionInfo,
|
||||
.ͼ2 .cm-tooltip.cm-cursorInfo,
|
||||
.ͼ2 .cm-tooltip-hover {
|
||||
// Add padding when infobox only contains text
|
||||
&:not(:has(div)) {
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.autocomplete-info-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip.cm-completionInfo {
|
||||
background-color: var(--color-background-xlight);
|
||||
border: var(--border-base);
|
||||
box-shadow: var(--box-shadow-light);
|
||||
clip-path: inset(-12px -12px -12px 0); // Clip box-shadow on the left
|
||||
border-left: none;
|
||||
border-bottom-right-radius: var(--border-radius-base);
|
||||
border-top-right-radius: var(--border-radius-base);
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
line-height: var(--font-line-height-loose);
|
||||
padding: 0;
|
||||
|
||||
// Add padding when infobox only contains text
|
||||
&:not(:has(div)) {
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
// Overwrite codemirror positioning
|
||||
top: 0 !important;
|
||||
left: 100% !important;
|
||||
right: auto !important;
|
||||
max-width: 320px !important;
|
||||
height: 100%;
|
||||
strong.autocomplete-info-arg,
|
||||
strong.autocomplete-info-arg-name {
|
||||
font-weight: var(--font-weight-regular);
|
||||
text-decoration: underline;
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.autocomplete-info-description {
|
||||
padding: 0 var(--spacing-xs);
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
.autocomplete-info-example {
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--color-infobox-examples-border-color);
|
||||
color: var(--color-text-base);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
font-family: var(--font-family);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: var(--font-line-height-loose);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-dark);
|
||||
@@ -160,31 +175,6 @@
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.autocomplete-info-description {
|
||||
padding: 0 var(--spacing-xs);
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
.autocomplete-info-example {
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--color-infobox-examples-border-color);
|
||||
color: var(--color-text-base);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
font-family: var(--font-family);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: var(--font-line-height-loose);
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete-info-args {
|
||||
padding: 0 var(--spacing-xs);
|
||||
list-style: none;
|
||||
@@ -272,6 +262,27 @@
|
||||
font-size: var(--font-size-3xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip.cm-completionInfo {
|
||||
background-color: var(--color-background-xlight);
|
||||
border: var(--border-base);
|
||||
box-shadow: var(--box-shadow-light);
|
||||
clip-path: inset(-12px -12px -12px 0); // Clip box-shadow on the left
|
||||
border-left: none;
|
||||
border-bottom-right-radius: var(--border-radius-base);
|
||||
border-top-right-radius: var(--border-radius-base);
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
line-height: var(--font-line-height-loose);
|
||||
padding: 0;
|
||||
|
||||
// Overwrite codemirror positioning
|
||||
top: 0 !important;
|
||||
left: 100% !important;
|
||||
right: auto !important;
|
||||
max-width: 320px !important;
|
||||
height: 100%;
|
||||
|
||||
&.cm-completionInfo-left-narrow,
|
||||
&.cm-completionInfo-right-narrow {
|
||||
@@ -293,3 +304,19 @@
|
||||
background-color: var(--color-infobox-background);
|
||||
}
|
||||
}
|
||||
|
||||
.ͼ2 .cm-tooltip.cm-cursorInfo,
|
||||
.ͼ2 .cm-tooltip-hover {
|
||||
background-color: var(--color-infobox-background);
|
||||
border: var(--border-base);
|
||||
box-shadow: var(--box-shadow-light);
|
||||
border-radius: var(--border-radius-base);
|
||||
line-height: var(--font-line-height-loose);
|
||||
padding: 0;
|
||||
max-width: 320px;
|
||||
|
||||
.autocomplete-info-container {
|
||||
height: auto;
|
||||
max-height: min(250px, 50vh);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user