feat(editor): Overhaul expression editor modal (#4631)

* feat(editor): Integrate CodeMirror into expression editor modal (#4563)

*  Initial setup

* 👕 Fix lint

*  Extract segments

*  Implement var insertion

* 👕 Ignore `.d.cts`

*  Refactor to simplify

*  Add brace handler

*  Fully replace input and output

* feat(editor): Adjust resolved expression to match parameter input hint (#4600)

*  Initial adjustments

* 🐛 Prevent empty decorations

*  Adjust resolved expression to match param input hint

* ✏️ Improve comment

* 👕 Remove lint rule

* ✏️ Fix typo

* ✏️ Fix closing brace

*  Clean up `displayableSegments()`

* feat(editor): Apply styling to expression editor modal (#4607)

🎨 Apply styling

* feat(core): Improve errors in evaluated expression (#4619)

* 🐛 Fix env var access for FE

* 🔥 Remove excess closing bracket

* 🚧 Set up TODO

* ✏️ Update copy

*  Deny env vars access to FE

* 👕 Remove unneeded lint exception

* 📘 Remove unneeded typing

* feat(editor): Dynamically delay evaluation resolution (#4625)

* ✏️ Update copy

*  Dynamically delay evaluation resolution

* 🔥 Remove unneeded computed property

* refactor(editor): Pre-review cleanup (#4627)

* 🔥 Remove `ExpressionInput` component

* 🔥 Remove Quill

* ✏️ Rename i18n key

* 🎨 Place border on correct element

* 🐛 Handle syntax errors

*  Add sample autocompletions

* 🐛 Fix auto-extending behavior

* feat(editor): Improve escaping behavior (#4641)

* 🎨 Hide hint on small screen

*  Improve escaping

* refactor(editor): Apply styling feedback to expression editor modal (#4660)

* 🎨 Restyle hint

* 🎨 Restyle param input hint

* 🔥 Remove `e.g.`

*  Tweak delay

* 🎨 Restyle output

* 🎨 Tweak theme

* ✏️ Tweak copy

* refactor(editor): Apply feedback 2022.11.22 (#4697)

* 🎨 Change background color

*  Focus on mount

*  Account for preexisting braces on injection

* 🐛 Fix `$workflow` showing as not saved

* ✏️ Tweak copy

* 🐛 Fix readonly focus

*  Focus input on paste

*  Sync inputs with modal

* ✏️ Tweak copy

* refactor(editor): Apply feedback 2022.11.23 (#4705)

*  Allow newlines

*  Set cursor at end of content

*  Do not defocus on paste on Chrome

*  Fix import

* 🧪 Add e2e tests

*  Cleanup

*  Add telemetry

* 🔥 Remove log

*  Expose error properties

* 🧪 Rename test

*  Move `getCurrentWorkflow()` call

*  Revert highlighting removal per feedback

*  Add i18n keys

* 🚚 Move computed property to local state

* 🎨 Use CSS vars

*  Update `pnpm-lock.yaml`

*  Apply readonly state

*  Use prop

*  Complete fix
This commit is contained in:
Iván Ovejero
2022-12-01 13:26:22 +01:00
committed by GitHub
parent 830bda5f55
commit 59771c80ea
33 changed files with 1043 additions and 637 deletions

View File

@@ -0,0 +1,281 @@
<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>

View File

@@ -0,0 +1,94 @@
<template>
<div ref="root" class="ph-no-capture" />
</template>
<script lang="ts">
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 type { Plaintext, Resolved, Segment } from './types';
export default Vue.extend({
name: 'expression-modal-output',
props: {
segments: {
type: Array as PropType<Segment[]>,
},
},
watch: {
segments() {
if (!this.editor) return;
this.editor.dispatch({
changes: { from: 0, to: this.editor.state.doc.length, insert: this.resolvedExpression },
});
addColor(this.editor, this.resolvedSegments);
removeColor(this.editor, this.plaintextSegments);
},
},
data() {
return {
editor: null as EditorView | null,
};
},
mounted() {
const extensions = [
EXPRESSION_EDITOR_THEME,
EditorState.readOnly.of(true),
EditorView.lineWrapping,
];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.resolvedExpression,
extensions,
}),
});
},
destroyed() {
this.editor?.destroy();
},
computed: {
resolvedExpression(): string {
return this.segments.reduce((acc, segment) => {
acc += segment.kind === 'resolvable' ? segment.resolved : segment.plaintext;
return acc;
}, '');
},
plaintextSegments(): Plaintext[] {
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
resolvedSegments(): Resolved[] {
let cursor = 0;
return this.segments
.map((segment) => {
segment.from = cursor;
cursor +=
segment.kind === 'plaintext'
? segment.plaintext.length
: (segment.resolved as any).toString().length;
segment.to = cursor;
return segment;
})
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
},
},
methods: {
getValue() {
return '=' + this.resolvedExpression;
},
},
});
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,57 @@
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];

View File

@@ -0,0 +1,94 @@
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 });
}

View File

@@ -0,0 +1,57 @@
'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;

View File

@@ -0,0 +1,5 @@
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 };

View File

@@ -0,0 +1,5 @@
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 };

View File

@@ -0,0 +1,51 @@
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 };

View File

@@ -0,0 +1,20 @@
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);
}

View File

@@ -0,0 +1,33 @@
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' },
],
};
}

View File

@@ -0,0 +1,62 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import { tags } from '@lezer/highlight';
export const SYNTAX_HIGHLIGHTING_CLASSES = {
validResolvable: 'cm-valid-resolvable',
invalidResolvable: 'cm-invalid-resolvable',
brokenResolvable: 'cm-broken-resolvable',
plaintext: 'cm-plaintext',
};
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 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)',
},
});

View File

@@ -0,0 +1,24 @@
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;
}