feat(editor): Inline expression editor (#4814)
* WIP * 🔥 Remove unneeded watch * ⚡ Further setup * ⚡ Fix import * ⚡ Minor tweaks * 🔥 Remove logging * 🎨 Add some styling * 🎨 More styling changes * 🐛 Fix wrong marking of stale data * 🎨 Prevent fx on dragging * 🔥 Remove logging * ⚡ Refine draggable target offsets * refactor(editor): Consolidate expression management logic (#4836) * ⚡ Extract `ExpressionFunctionIcon` * ⚡ Simplify syntax * ⚡ Move to mixin * 🎨 Format * 📘 Unify types * ⚡ Dedup double brace handler * ⚡ Consolidate resolvable highlighter * 🎨 Format * ⚡ Consolidate language pack * ✏️ Add comment * ⚡ Move completions to plugins * ⚡ Partially deduplicate themes * refactor(editor): Apply styling feedback to inline expression editor (#4846) * 🎨 Adjust styling for expression parameter input * 🎨 Style outputs differently * ⚡ Set single line for RLC * 🎨 Style both openers identically * 🐛 Prevent defocus on resize * ⚡ Adjust line height * 🎨 Adjust border with for expression input * ⚡ Fix font family for inline output * ⚡ Set up telemetry * ⚡ Complete telemetry * ⚡ Simplify event source * ⚡ Set monospaced font for inline output * 🎨 Hide cursor on schema pill drop * 🧪 Update snapshots * ⚡ Consolidate editor styles * ✏️ Add tech debt comments * ⚡ Improve naming * ⚡ Improve inside resolvable detection * ⚡ Improve var naming * 🔥 Remove outdated comment * 🚚 Move constant to data * ✏️ Clarify comments * 🔥 Remove outdated comments * 🔥 Remove unneeded try-catch * 🔥 Remove unneeded method * 🔥 Remove unneeded check * 🔥 Remove `openExpression` check * 🔥 Remove unused timeout * 🔥 Remove commented out sections * ⚡ Use Pinia naming convention * ⚡ Re-evaluate on change of `ndvInputData` * 🐛 Fix handling of `0` in number-type input * 🐛 Surface focus and blur for mapping hints * 🔥 Remove logging * ✏️ Reword error * ⚡ Change kebab-case to PascalCase * ⚡ Refactor state fields for clarity * ⚡ Support double bracing on selection * 🎨 More styling * ⚡ Miscellaneous cleanup * ⚡ Disregard error on drop * 🎨 Fix schema pill styling * 🎨 More `background` to `background-color` fixes * 🧪 Update snapshots * 🎨 Replace non-existing var with white * 🧪 Update snapshot * 📦 Integrate `codemirror-lang-n8n-expression` * 🎨 Fix formatting * 🧪 Re-update test snapshots * 🧪 Update selectors for inline editor * 🔥 Remove unused test ID * 📘 Add type for `currentNodePaneType` * ⚡ Refactor mixin to util * ⚡ Use `:global` * 🔥 Remove comment * ⚡ Add watch * ⚡ Change import style * 👕 Fix lint * ⚡ Refactor preventing blur on resize * 🔥 Remove comment * 🧪 Re-update snapshots * 🎨 Prettify * 👕 Fix lint * 🔥 Remove comment Co-authored-by: Mutasem <mutdmour@gmail.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div ref="root" class="ph-no-capture"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { history } from '@codemirror/commands';
|
||||
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler';
|
||||
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { inputTheme } from './theme';
|
||||
|
||||
import type { IVariableItemSelected } from '@/Interface';
|
||||
|
||||
export default mixins(expressionManager, workflowHelpers).extend({
|
||||
name: 'ExpressionEditorModalInput',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: null as EditorView | null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const extensions = [
|
||||
inputTheme(),
|
||||
n8nLanguageSupport(),
|
||||
history(),
|
||||
doubleBraceHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.updateListener.of((viewUpdate) => {
|
||||
if (!this.editor || !viewUpdate.docChanged) return;
|
||||
|
||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
setTimeout(() => this.editor?.focus()); // prevent blur on paste
|
||||
|
||||
setTimeout(() => {
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
}, this.evaluationDelay);
|
||||
}),
|
||||
];
|
||||
|
||||
this.editor = new EditorView({
|
||||
parent: this.$refs.root as HTMLDivElement,
|
||||
state: EditorState.create({
|
||||
doc: this.value.startsWith('=') ? this.value.slice(1) : this.value,
|
||||
extensions,
|
||||
}),
|
||||
});
|
||||
|
||||
this.editor.focus();
|
||||
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.editor.dispatch({
|
||||
selection: { anchor: this.editor.state.doc.length },
|
||||
});
|
||||
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
},
|
||||
destroyed() {
|
||||
this.editor?.destroy();
|
||||
},
|
||||
methods: {
|
||||
itemSelected({ variable }: IVariableItemSelected) {
|
||||
if (!this.editor || this.isReadOnly) return;
|
||||
|
||||
const OPEN_MARKER = '{{';
|
||||
const CLOSE_MARKER = '}}';
|
||||
|
||||
const { doc, selection } = this.editor.state;
|
||||
const { head } = selection.main;
|
||||
|
||||
const isInsideResolvable =
|
||||
doc.toString().slice(0, head).includes(OPEN_MARKER) &&
|
||||
doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
|
||||
|
||||
const insert = isInsideResolvable
|
||||
? variable
|
||||
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
|
||||
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
from: head,
|
||||
insert,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="root" class="ph-no-capture" />
|
||||
<div ref="root" class="ph-no-capture"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -7,13 +7,13 @@ import Vue, { PropType } from 'vue';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
import { EXPRESSION_EDITOR_THEME } from './theme';
|
||||
import { addColor, removeColor } from './colorDecorations';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { outputTheme } from './theme';
|
||||
|
||||
import type { Plaintext, Resolved, Segment } from './types';
|
||||
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'expression-modal-output',
|
||||
name: 'ExpressionEditorModalOutput',
|
||||
props: {
|
||||
segments: {
|
||||
type: Array as PropType<Segment[]>,
|
||||
@@ -27,8 +27,8 @@ export default Vue.extend({
|
||||
changes: { from: 0, to: this.editor.state.doc.length, insert: this.resolvedExpression },
|
||||
});
|
||||
|
||||
addColor(this.editor, this.resolvedSegments);
|
||||
removeColor(this.editor, this.plaintextSegments);
|
||||
highlighter.addColor(this.editor, this.resolvedSegments);
|
||||
highlighter.removeColor(this.editor, this.plaintextSegments);
|
||||
},
|
||||
},
|
||||
data() {
|
||||
@@ -37,11 +37,7 @@ export default Vue.extend({
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const extensions = [
|
||||
EXPRESSION_EDITOR_THEME,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
const extensions = [outputTheme(), EditorState.readOnly.of(true), EditorView.lineWrapping];
|
||||
|
||||
this.editor = new EditorView({
|
||||
parent: this.$refs.root as HTMLDivElement,
|
||||
@@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<div ref="root" class="ph-no-capture" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { mapStores } from 'pinia';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { history } from '@codemirror/commands';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { useNDVStore } from '@/stores/ndv';
|
||||
import { n8nLanguageSupport } from './n8nLanguageSupport';
|
||||
import { braceHandler } from './braceHandler';
|
||||
import { EXPRESSION_EDITOR_THEME } from './theme';
|
||||
import { addColor, removeColor } from './colorDecorations';
|
||||
|
||||
import type { IVariableItemSelected } from '@/Interface';
|
||||
import type { RawSegment, Segment, Resolvable, Plaintext } from './types';
|
||||
|
||||
const EVALUATION_DELAY = 300; // ms
|
||||
|
||||
export default mixins(workflowHelpers).extend({
|
||||
name: 'expression-modal-input',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: null as EditorView | null,
|
||||
errorsInSuccession: 0,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const extensions = [
|
||||
EXPRESSION_EDITOR_THEME,
|
||||
n8nLanguageSupport(),
|
||||
history(),
|
||||
braceHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.updateListener.of((viewUpdate) => {
|
||||
if (!this.editor || !viewUpdate.docChanged) return;
|
||||
|
||||
removeColor(this.editor, this.plaintextSegments);
|
||||
|
||||
addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
const prevErrorsInSuccession = this.errorsInSuccession;
|
||||
|
||||
if (this.resolvableSegments.filter((s) => s.error).length > 0) {
|
||||
this.errorsInSuccession += 1;
|
||||
} else {
|
||||
this.errorsInSuccession = 0;
|
||||
}
|
||||
|
||||
const addsNewError = this.errorsInSuccession > prevErrorsInSuccession;
|
||||
|
||||
let delay = EVALUATION_DELAY;
|
||||
|
||||
if (addsNewError && this.errorsInSuccession > 1 && this.errorsInSuccession < 5) {
|
||||
delay = EVALUATION_DELAY * this.errorsInSuccession;
|
||||
} else if (addsNewError && this.errorsInSuccession >= 5) {
|
||||
delay = 0;
|
||||
}
|
||||
|
||||
setTimeout(() => this.editor?.focus()); // prevent blur on paste
|
||||
|
||||
setTimeout(() => {
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
}, delay);
|
||||
}),
|
||||
];
|
||||
|
||||
this.editor = new EditorView({
|
||||
parent: this.$refs.root as HTMLDivElement,
|
||||
state: EditorState.create({
|
||||
doc: this.value.startsWith('=') ? this.value.slice(1) : this.value,
|
||||
extensions,
|
||||
}),
|
||||
});
|
||||
|
||||
this.editor.focus();
|
||||
|
||||
addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.editor.dispatch({
|
||||
selection: { anchor: this.editor.state.doc.length },
|
||||
});
|
||||
|
||||
this.$emit('change', { value: this.unresolvedExpression, segments: this.displayableSegments });
|
||||
},
|
||||
destroyed() {
|
||||
this.editor?.destroy();
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore),
|
||||
unresolvedExpression(): string {
|
||||
return this.segments.reduce((acc, segment) => {
|
||||
acc += segment.kind === 'resolvable' ? segment.resolvable : segment.plaintext;
|
||||
|
||||
return acc;
|
||||
}, '=');
|
||||
},
|
||||
resolvableSegments(): Resolvable[] {
|
||||
return this.segments.filter((s): s is Resolvable => s.kind === 'resolvable');
|
||||
},
|
||||
plaintextSegments(): Plaintext[] {
|
||||
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
|
||||
},
|
||||
|
||||
/**
|
||||
* Some segments are conditionally displayed, i.e. not displayed when part of the
|
||||
* expression result but displayed when the entire result.
|
||||
*
|
||||
* Example:
|
||||
* - Expression `This is a {{ null }} test` is displayed as `This is a test`.
|
||||
* - Expression `{{ null }}` is displayed as `[Object: null]`.
|
||||
*
|
||||
* Conditionally displayed segments:
|
||||
* - `[Object: null]`
|
||||
* - `[Array: []]`
|
||||
* - `[empty]` (from `''`, not from `undefined`)
|
||||
* - `null` (from `NaN`)
|
||||
*
|
||||
* For these two segments, display differs based on context:
|
||||
* - Date displayed as
|
||||
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
|
||||
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
|
||||
* - Non-empty array displayed as
|
||||
* - `1,2,3` when part of the result
|
||||
* - `[Array: [1, 2, 3]]` when the entire result
|
||||
*
|
||||
*/
|
||||
displayableSegments(): Segment[] {
|
||||
return this.segments
|
||||
.map((s) => {
|
||||
if (this.segments.length <= 1 || s.kind !== 'resolvable') return s;
|
||||
|
||||
if (typeof s.resolved === 'string' && /\[Object: "\d{4}-\d{2}-\d{2}T/.test(s.resolved)) {
|
||||
const utcDateString = s.resolved.replace(/(\[Object: "|\"\])/g, '');
|
||||
s.resolved = new Date(utcDateString).toString();
|
||||
}
|
||||
|
||||
if (typeof s.resolved === 'string' && /\[Array:\s\[.+\]\]/.test(s.resolved)) {
|
||||
s.resolved = s.resolved.replace(/(\[Array: \[|\])/g, '');
|
||||
}
|
||||
|
||||
return s;
|
||||
})
|
||||
.filter((s) => {
|
||||
if (
|
||||
this.segments.length > 1 &&
|
||||
s.kind === 'resolvable' &&
|
||||
typeof s.resolved === 'string' &&
|
||||
(['[Object: null]', '[Array: []]'].includes(s.resolved) ||
|
||||
s.resolved === this.$locale.baseText('expressionModalInput.empty') ||
|
||||
s.resolved === this.$locale.baseText('expressionModalInput.null'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
segments(): Segment[] {
|
||||
if (!this.editor) return [];
|
||||
|
||||
const rawSegments: RawSegment[] = [];
|
||||
|
||||
syntaxTree(this.editor.state)
|
||||
.cursor()
|
||||
.iterate((node) => {
|
||||
if (!this.editor || node.type.name === 'Program') return;
|
||||
|
||||
rawSegments.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: this.editor.state.sliceDoc(node.from, node.to),
|
||||
type: node.type.name,
|
||||
});
|
||||
});
|
||||
|
||||
return rawSegments.reduce<Segment[]>((acc, segment) => {
|
||||
const { from, to, text, type } = segment;
|
||||
|
||||
if (type === 'Resolvable') {
|
||||
const { resolved, error, fullError } = this.resolve(text);
|
||||
|
||||
acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError });
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
// broken resolvable included in plaintext
|
||||
|
||||
acc.push({ kind: 'plaintext', from, to, plaintext: text });
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isEmptyExpression(resolvable: string) {
|
||||
return /\{\{\s*\}\}/.test(resolvable);
|
||||
},
|
||||
resolve(resolvable: string) {
|
||||
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
|
||||
resolved: undefined,
|
||||
error: false,
|
||||
fullError: null,
|
||||
};
|
||||
|
||||
try {
|
||||
result.resolved = this.resolveExpression('=' + resolvable, undefined, {
|
||||
inputNodeName: this.ndvStore.ndvInputNodeName,
|
||||
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
||||
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
||||
});
|
||||
} catch (error) {
|
||||
result.resolved = `[${error.message}]`;
|
||||
result.error = true;
|
||||
result.fullError = error;
|
||||
}
|
||||
|
||||
if (result.resolved === '') {
|
||||
result.resolved = this.$locale.baseText('expressionModalInput.empty');
|
||||
}
|
||||
|
||||
if (result.resolved === undefined && this.isEmptyExpression(resolvable)) {
|
||||
result.resolved = this.$locale.baseText('expressionModalInput.empty');
|
||||
}
|
||||
|
||||
if (result.resolved === undefined) {
|
||||
result.resolved = this.$locale.baseText('expressionModalInput.undefined');
|
||||
result.error = true;
|
||||
}
|
||||
|
||||
if (typeof result.resolved === 'number' && isNaN(result.resolved)) {
|
||||
result.resolved = this.$locale.baseText('expressionModalInput.null');
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
itemSelected({ variable }: IVariableItemSelected) {
|
||||
if (!this.editor || this.isReadOnly) return;
|
||||
|
||||
const OPEN_MARKER = '{{';
|
||||
const CLOSE_MARKER = '}}';
|
||||
|
||||
const { doc, selection } = this.editor.state;
|
||||
const { head } = selection.main;
|
||||
|
||||
const beforeBraced = doc.toString().slice(0, head).includes(OPEN_MARKER);
|
||||
const afterBraced = doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
|
||||
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
from: head,
|
||||
insert:
|
||||
beforeBraced && afterBraced
|
||||
? variable
|
||||
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' '),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
@@ -1,57 +0,0 @@
|
||||
import { closeBrackets, insertBracket } from '@codemirror/autocomplete';
|
||||
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
const braceInputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
||||
if (view.composing || view.state.readOnly) return false;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
|
||||
if (
|
||||
insert.length > 2 ||
|
||||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
|
||||
from !== selection.from ||
|
||||
to !== selection.to
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transaction = insertBracket(view.state, insert);
|
||||
|
||||
if (!transaction) return false;
|
||||
|
||||
view.dispatch(transaction);
|
||||
|
||||
// customization to rearrange spacing and cursor for expression
|
||||
|
||||
const cursor = view.state.selection.main.head;
|
||||
|
||||
const isSecondBraceForNewExpression =
|
||||
view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
|
||||
view.state.sliceDoc(cursor, cursor + 1) === '}';
|
||||
|
||||
if (isSecondBraceForNewExpression) {
|
||||
view.dispatch({
|
||||
changes: { from: cursor, to: cursor + 2, insert: ' }' },
|
||||
selection: { anchor: cursor + 1 },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const isFirstBraceForNewExpression =
|
||||
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
|
||||
view.state.sliceDoc(cursor, cursor + 1) === '}';
|
||||
|
||||
if (isFirstBraceForNewExpression) {
|
||||
view.dispatch({ changes: { from: cursor, insert: ' ' } });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const [_, bracketState] = closeBrackets() as readonly Extension[];
|
||||
|
||||
export const braceHandler = () => [braceInputHandler, bracketState];
|
||||
@@ -1,94 +0,0 @@
|
||||
import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
|
||||
import { StateField, StateEffect } from '@codemirror/state';
|
||||
|
||||
import { DYNAMICALLY_STYLED_RESOLVABLES_THEME, SYNTAX_HIGHLIGHTING_CLASSES } from './theme';
|
||||
|
||||
import type { ColoringStateEffect, Plaintext, Resolvable, Resolved } from './types';
|
||||
|
||||
const stateEffects = {
|
||||
addColor: StateEffect.define<ColoringStateEffect.Value>({
|
||||
map: ({ from, to, kind, error }, change) => ({
|
||||
from: change.mapPos(from),
|
||||
to: change.mapPos(to),
|
||||
kind,
|
||||
error,
|
||||
}),
|
||||
}),
|
||||
removeColor: StateEffect.define<{ from: number; to: number }>({
|
||||
map: ({ from, to }, change) => ({
|
||||
from: change.mapPos(from),
|
||||
to: change.mapPos(to),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
const marks = {
|
||||
valid: Decoration.mark({ class: SYNTAX_HIGHLIGHTING_CLASSES.validResolvable }),
|
||||
invalid: Decoration.mark({ class: SYNTAX_HIGHLIGHTING_CLASSES.invalidResolvable }),
|
||||
};
|
||||
|
||||
const coloringField = StateField.define<DecorationSet>({
|
||||
provide: (stateField) => EditorView.decorations.from(stateField),
|
||||
create() {
|
||||
return Decoration.none;
|
||||
},
|
||||
update(colorings, transaction) {
|
||||
colorings = colorings.map(transaction.changes);
|
||||
|
||||
for (const txEffect of transaction.effects) {
|
||||
if (txEffect.is(stateEffects.removeColor)) {
|
||||
colorings = colorings.update({
|
||||
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
|
||||
});
|
||||
}
|
||||
|
||||
if (txEffect.is(stateEffects.addColor)) {
|
||||
colorings = colorings.update({
|
||||
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
|
||||
});
|
||||
|
||||
const decoration = txEffect.value.error ? marks.invalid : marks.valid;
|
||||
|
||||
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
|
||||
|
||||
colorings = colorings.update({
|
||||
add: [decoration.range(txEffect.value.from, txEffect.value.to)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return colorings;
|
||||
},
|
||||
});
|
||||
|
||||
export function addColor(view: EditorView, segments: Array<Resolvable | Resolved>) {
|
||||
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to, kind, error }) =>
|
||||
stateEffects.addColor.of({ from, to, kind, error }),
|
||||
);
|
||||
|
||||
if (effects.length === 0) return;
|
||||
|
||||
if (!view.state.field(coloringField, false)) {
|
||||
effects.push(
|
||||
StateEffect.appendConfig.of([coloringField, DYNAMICALLY_STYLED_RESOLVABLES_THEME]),
|
||||
);
|
||||
}
|
||||
|
||||
view.dispatch({ effects });
|
||||
}
|
||||
|
||||
export function removeColor(view: EditorView, segments: Plaintext[]) {
|
||||
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to }) =>
|
||||
stateEffects.removeColor.of({ from, to }),
|
||||
);
|
||||
|
||||
if (effects.length === 0) return;
|
||||
|
||||
if (!view.state.field(coloringField, false)) {
|
||||
effects.push(
|
||||
StateEffect.appendConfig.of([coloringField, DYNAMICALLY_STYLED_RESOLVABLES_THEME]),
|
||||
);
|
||||
}
|
||||
|
||||
view.dispatch({ effects });
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
var autocomplete = require('@codemirror/autocomplete');
|
||||
var lr = require('@lezer/lr');
|
||||
var language = require('@codemirror/language');
|
||||
var highlight = require('@lezer/highlight');
|
||||
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
const parser = lr.LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_",
|
||||
stateData: ']~OQPORPOSPO~O',
|
||||
goto: 'cWPPPPPXP_QRORSRTQOR',
|
||||
nodeNames: '⚠ Program Plaintext Resolvable BrokenResolvable',
|
||||
maxTerm: 7,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData:
|
||||
'4f~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!a!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r4W#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~&URS~O#q&P#q#r&_#r~&P~&dOS~~&i!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r*X#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~*^RS~O#q*g#q#r0s#r~*g~*j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~-j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r0g#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~0jRO#q*g#q#r0s#r~*g~0x}R~X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~3xRO#q*g#q#r4R#r~*g~4WOR~~4]RS~O#q*g#q#r4R#r~*g',
|
||||
tokenizers: [0],
|
||||
topRules: { Program: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
|
||||
const parserWithMetaData = parser.configure({
|
||||
props: [
|
||||
language.foldNodeProp.add({
|
||||
Application: language.foldInside,
|
||||
}),
|
||||
highlight.styleTags({
|
||||
OpenMarker: highlight.tags.brace,
|
||||
CloseMarker: highlight.tags.brace,
|
||||
Plaintext: highlight.tags.content,
|
||||
Resolvable: highlight.tags.string,
|
||||
BrokenResolvable: highlight.tags.className,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const n8nExpressionLanguage = language.LRLanguage.define({
|
||||
parser: parserWithMetaData,
|
||||
languageData: {
|
||||
commentTokens: { line: ';' },
|
||||
},
|
||||
});
|
||||
const completions = n8nExpressionLanguage.data.of({
|
||||
autocomplete: autocomplete.completeFromList([{ label: 'abcdefg', type: 'keyword' }]),
|
||||
});
|
||||
function n8nExpression() {
|
||||
return new language.LanguageSupport(n8nExpressionLanguage, [completions]);
|
||||
}
|
||||
|
||||
exports.n8nExpression = n8nExpression;
|
||||
exports.n8nExpressionLanguage = n8nExpressionLanguage;
|
||||
exports.parserWithMetaData = parserWithMetaData;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LRLanguage, LanguageSupport } from '@codemirror/language';
|
||||
declare const parserWithMetaData: import('@lezer/lr').LRParser;
|
||||
declare const n8nExpressionLanguage: LRLanguage;
|
||||
declare function n8nExpression(): LanguageSupport;
|
||||
export { parserWithMetaData, n8nExpressionLanguage, n8nExpression };
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LRLanguage, LanguageSupport } from '@codemirror/language';
|
||||
declare const parserWithMetaData: import('@lezer/lr').LRParser;
|
||||
declare const n8nExpressionLanguage: LRLanguage;
|
||||
declare function n8nExpression(): LanguageSupport;
|
||||
export { parserWithMetaData, n8nExpressionLanguage, n8nExpression };
|
||||
@@ -1,50 +0,0 @@
|
||||
import { completeFromList } from '@codemirror/autocomplete';
|
||||
import { LRParser } from '@lezer/lr';
|
||||
import { foldNodeProp, foldInside, LRLanguage, LanguageSupport } from '@codemirror/language';
|
||||
import { styleTags, tags } from '@lezer/highlight';
|
||||
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_",
|
||||
stateData: ']~OQPORPOSPO~O',
|
||||
goto: 'cWPPPPPXP_QRORSRTQOR',
|
||||
nodeNames: '⚠ Program Plaintext Resolvable BrokenResolvable',
|
||||
maxTerm: 7,
|
||||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData:
|
||||
'4f~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!a!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r4W#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~&URS~O#q&P#q#r&_#r~&P~&dOS~~&i!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r*X#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~*^RS~O#q*g#q#r0s#r~*g~*j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~-j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r0g#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~0jRO#q*g#q#r0s#r~*g~0x}R~X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~3xRO#q*g#q#r4R#r~*g~4WOR~~4]RS~O#q*g#q#r4R#r~*g',
|
||||
tokenizers: [0],
|
||||
topRules: { Program: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
});
|
||||
|
||||
const parserWithMetaData = parser.configure({
|
||||
props: [
|
||||
foldNodeProp.add({
|
||||
Application: foldInside,
|
||||
}),
|
||||
styleTags({
|
||||
OpenMarker: tags.brace,
|
||||
CloseMarker: tags.brace,
|
||||
Plaintext: tags.content,
|
||||
Resolvable: tags.string,
|
||||
BrokenResolvable: tags.className,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const n8nExpressionLanguage = LRLanguage.define({
|
||||
parser: parserWithMetaData,
|
||||
languageData: {
|
||||
commentTokens: { line: ';' },
|
||||
},
|
||||
});
|
||||
const completions = n8nExpressionLanguage.data.of({
|
||||
autocomplete: completeFromList([{ label: 'abcdefg', type: 'keyword' }]),
|
||||
});
|
||||
function n8nExpression() {
|
||||
return new LanguageSupport(n8nExpressionLanguage, [completions]);
|
||||
}
|
||||
|
||||
export { n8nExpression, n8nExpressionLanguage, parserWithMetaData };
|
||||
@@ -1,20 +0,0 @@
|
||||
import { LanguageSupport, LRLanguage } from '@codemirror/language';
|
||||
import { parseMixed } from '@lezer/common';
|
||||
import { parser as jsParser } from '@lezer/javascript';
|
||||
import { parserWithMetaData as n8nParser } from './n8nLanguagePack';
|
||||
|
||||
const parserWithNestedJsParser = n8nParser.configure({
|
||||
wrap: parseMixed((node) => {
|
||||
if (node.type.isTop) return null;
|
||||
|
||||
return node.name === 'Resolvable'
|
||||
? { parser: jsParser, overlay: (node) => node.type.name === 'Resolvable' }
|
||||
: null;
|
||||
}),
|
||||
});
|
||||
|
||||
const n8nLanguage = LRLanguage.define({ parser: parserWithNestedJsParser });
|
||||
|
||||
export function n8nLanguageSupport() {
|
||||
return new LanguageSupport(n8nLanguage);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
|
||||
/**
|
||||
* Completions available inside the resolvable segment `{{ ... }}` of an n8n expression.
|
||||
*
|
||||
* Currently unused.
|
||||
*/
|
||||
export function resolvableCompletions(context: CompletionContext): CompletionResult | null {
|
||||
const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
|
||||
|
||||
if (nodeBefore.name !== 'Resolvable') return null;
|
||||
|
||||
const pattern = /(?<quotedString>('|")\w*('|"))\./;
|
||||
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const match = preCursor.text.match(pattern);
|
||||
|
||||
if (!match?.groups?.quotedString) return null;
|
||||
|
||||
const { quotedString } = match.groups;
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: [
|
||||
{ label: `${quotedString}.replace()`, info: 'Replace part of a string with another' },
|
||||
{ label: `${quotedString}.slice()`, info: 'Copy part of a string' },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,62 +1,47 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
|
||||
export const SYNTAX_HIGHLIGHTING_CLASSES = {
|
||||
validResolvable: 'cm-valid-resolvable',
|
||||
invalidResolvable: 'cm-invalid-resolvable',
|
||||
brokenResolvable: 'cm-broken-resolvable',
|
||||
plaintext: 'cm-plaintext',
|
||||
const commonThemeProps = {
|
||||
'&': {
|
||||
borderWidth: 'var(--border-width-base)',
|
||||
borderStyle: 'var(--input-border-style, var(--border-style-base))',
|
||||
borderColor: 'var(--input-border-color, var(--border-color-base))',
|
||||
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
|
||||
backgroundColor: 'var(--color-expression-editor-background)',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
borderColor: 'var(--color-secondary)',
|
||||
outline: '0 !important',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: 'var(--font-family-monospace)',
|
||||
height: '220px',
|
||||
padding: 'var(--spacing-xs)',
|
||||
color: 'var(--input-font-color, var(--color-text-dark))',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
},
|
||||
};
|
||||
|
||||
export const EXPRESSION_EDITOR_THEME = [
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
borderWidth: 'var(--border-width-base)',
|
||||
borderStyle: 'var(--input-border-style, var(--border-style-base))',
|
||||
borderColor: 'var(--input-border-color, var(--border-color-base))',
|
||||
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
|
||||
backgroundColor: 'var(--color-expression-editor-background)',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
borderColor: 'var(--color-secondary)',
|
||||
outline: 'unset !important',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
|
||||
height: '220px',
|
||||
padding: 'var(--spacing-xs)',
|
||||
color: 'var(--input-font-color, var(--color-text-dark))',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
},
|
||||
}),
|
||||
syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{
|
||||
tag: tags.content,
|
||||
class: SYNTAX_HIGHLIGHTING_CLASSES.plaintext,
|
||||
},
|
||||
{
|
||||
tag: tags.className,
|
||||
class: SYNTAX_HIGHLIGHTING_CLASSES.brokenResolvable,
|
||||
},
|
||||
/**
|
||||
* Resolvables are dynamically styled with
|
||||
* `cm-valid-resolvable` and `cm-invalid-resolvable`
|
||||
*/
|
||||
]),
|
||||
),
|
||||
];
|
||||
export const inputTheme = () => {
|
||||
const theme = EditorView.theme(commonThemeProps);
|
||||
|
||||
export const DYNAMICALLY_STYLED_RESOLVABLES_THEME = EditorView.theme({
|
||||
['.' + SYNTAX_HIGHLIGHTING_CLASSES.validResolvable]: {
|
||||
color: 'var(--color-valid-resolvable-foreground)',
|
||||
backgroundColor: 'var(--color-valid-resolvable-background)',
|
||||
},
|
||||
['.' + SYNTAX_HIGHLIGHTING_CLASSES.invalidResolvable]: {
|
||||
color: 'var(--color-invalid-resolvable-foreground)',
|
||||
backgroundColor: 'var(--color-invalid-resolvable-background)',
|
||||
},
|
||||
});
|
||||
return [theme, highlighter.resolvableStyle];
|
||||
};
|
||||
|
||||
export const outputTheme = () => {
|
||||
const theme = EditorView.theme({
|
||||
...commonThemeProps,
|
||||
'.cm-valid-resolvable': {
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
'.cm-invalid-resolvable': {
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
});
|
||||
|
||||
return [theme, highlighter.resolvableStyle];
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
type Range = { from: number; to: number };
|
||||
|
||||
export type RawSegment = { text: string; type: string } & Range;
|
||||
|
||||
export type Segment = Plaintext | Resolvable;
|
||||
|
||||
export type Plaintext = { kind: 'plaintext'; plaintext: string } & Range;
|
||||
|
||||
export type Resolvable = {
|
||||
kind: 'resolvable';
|
||||
resolvable: string;
|
||||
resolved: unknown;
|
||||
error: boolean;
|
||||
fullError: Error | null;
|
||||
} & Range;
|
||||
|
||||
export type Resolved = Resolvable;
|
||||
|
||||
export namespace ColoringStateEffect {
|
||||
export type Value = {
|
||||
kind: 'plaintext' | 'resolvable';
|
||||
error: boolean;
|
||||
} & Range;
|
||||
}
|
||||
Reference in New Issue
Block a user