UNPKG

@bpmn-io/feel-editor

Version:
728 lines (618 loc) 16.8 kB
'use strict'; var autocomplete = require('@codemirror/autocomplete'); var commands = require('@codemirror/commands'); var language$1 = require('@codemirror/language'); var lint = require('@codemirror/lint'); var state = require('@codemirror/state'); var view = require('@codemirror/view'); var feelLint = require('@bpmn-io/feel-lint'); var highlight = require('@lezer/highlight'); var langFeel = require('lang-feel'); var minDom = require('min-dom'); var feelBuiltins = require('@camunda/feel-builtins'); var linter = [ lint.linter(feelLint.cmFeelLinter()) ]; const baseTheme = view.EditorView.theme({ '& .cm-content': { padding: '0px', }, '& .cm-line': { padding: '0px', }, '&.cm-editor.cm-focused': { outline: 'none', }, '& .cm-completionInfo': { whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }, '&.cm-editor': { height: '100%', }, // Don't wrap whitespace for custom HTML '& .cm-completionInfo > *': { whiteSpace: 'normal' }, '& .cm-completionInfo ul': { margin: 0, paddingLeft: '15px' }, '& .cm-completionInfo pre': { marginBottom: 0, whiteSpace: 'pre-wrap' }, '& .cm-completionInfo p': { marginTop: 0, }, '& .cm-completionInfo p:not(:last-of-type)': { marginBottom: 0, } }); const highlightTheme = view.EditorView.baseTheme({ '& .variableName': { color: '#10f' }, '& .number': { color: '#164' }, '& .string': { color: '#a11' }, '& .bool': { color: '#219' }, '& .function': { color: '#aa3731', fontWeight: 'bold' }, '& .control': { color: '#708' } }); const syntaxClasses = language$1.syntaxHighlighting( language$1.HighlightStyle.define([ { tag: highlight.tags.variableName, class: 'variableName' }, { tag: highlight.tags.name, class: 'variableName' }, { tag: highlight.tags.number, class: 'number' }, { tag: highlight.tags.string, class: 'string' }, { tag: highlight.tags.bool, class: 'bool' }, { tag: highlight.tags.function(highlight.tags.variableName), class: 'function' }, { tag: highlight.tags.function(highlight.tags.special(highlight.tags.variableName)), class: 'function' }, { tag: highlight.tags.controlKeyword, class: 'control' }, { tag: highlight.tags.operatorKeyword, class: 'control' } ]) ); var theme = [ baseTheme, highlightTheme, syntaxClasses ]; // helpers /////////////////////////////// function _isEmpty(node) { return node && node.from === node.to; } /** * @param {any} node * @param {number} pos * * @return {boolean} */ function isEmpty(node, pos) { // For the special case of empty nodes, we need to check the current node // as well. The previous node could be part of another token, e.g. // when typing functions "abs(". const nextNode = node.nextSibling; return _isEmpty(node) || ( nextNode && nextNode.from === pos && _isEmpty(nextNode) ); } function isVariableName(node) { return node && node.parent && node.parent.name === 'VariableName'; } function isPathExpression(node) { if (!node) { return false; } if (node.name === 'PathExpression') { return true; } return isPathExpression(node.parent); } /** * @typedef { import('../core').Variable } Variable * @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource */ /** * @param { { * variables?: Variable[], * } } options * * @return { CompletionSource } */ function pathExpressionCompletion({ variables }) { return (context) => { const nodeBefore = language$1.syntaxTree(context.state).resolve(context.pos, -1); if (!isPathExpression(nodeBefore)) { return; } const expression = findPathExpression(nodeBefore); // if the cursor is directly after the `.`, variable starts at the cursor position const from = nodeBefore === expression ? context.pos : nodeBefore.from; const path = getPath(expression, context); let options = variables; for (var i = 0; i < path.length - 1; i++) { var childVar = options.find(val => val.name === path[i].name); if (!childVar) { return null; } // only suggest if variable type matches if ( childVar.isList !== 'optional' && !!childVar.isList !== path[i].isList ) { return; } options = childVar.entries; } if (!options) return; options = options.map(v => ({ label: v.name, type: 'variable', info: v.info, detail: v.detail })); const result = { from: from, options: options }; return result; }; } function findPathExpression(node) { while (node) { if (node.name === 'PathExpression') { return node; } node = node.parent; } } // parses the path expression into a list of variable names with type information // e.g. foo[0].bar => [ { name: 'foo', isList: true }, { name: 'bar', isList: false } ] function getPath(node, context) { let path = []; for (let child = node.firstChild; child; child = child.nextSibling) { if (child.name === 'PathExpression') { path.push(...getPath(child, context)); } else if (child.name === 'FilterExpression') { path.push(...getFilter(child, context)); } else { path.push({ name: getNodeContent(child, context), isList: false }); } } return path; } function getFilter(node, context) { const list = node.firstChild; if (list.name === 'PathExpression') { const path = getPath(list, context); const last = path[path.length - 1]; last.isList = true; return path; } return [ { name: getNodeContent(list, context), isList: true } ]; } function getNodeContent(node, context) { return context.state.sliceDoc(node.from, node.to); } /** * @typedef { import('../core').Variable } Variable * @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource */ /** * @param { { * variables?: Variable[], * builtins?: Variable[] * } } options * * @return { CompletionSource } */ function variableCompletion({ variables = [], builtins = [] }) { const options = getVariableSuggestions(variables, builtins); if (!options.length) { return (context) => null; } return (context) => { const { pos, state } = context; // in most cases, use what is typed before the cursor const nodeBefore = language$1.syntaxTree(state).resolve(pos, -1); if (isEmpty(nodeBefore, pos)) { return context.explicit ? { from: pos, options } : null; } // only auto-complete variables if (!isVariableName(nodeBefore) || isPathExpression(nodeBefore)) { return null; } return { from: nodeBefore.from, options }; }; } /** * @param { Variable[] } variables * @param { Variable[] } builtins * * @returns {import('@codemirror/autocomplete').Completion[]} */ function getVariableSuggestions(variables, builtins) { return [].concat( variables.map(v => createVariableSuggestion(v)), builtins.map(b => createVariableSuggestion(b)) ); } /** * @param {import('..').Variable} variable * @param {number} boost * @returns {import('@codemirror/autocomplete').Completion} */ function createVariableSuggestion(variable, boost) { if (variable.type === 'function') { return createFunctionVariable(variable, boost); } return { label: variable.name, type: 'variable', info: variable.info, detail: variable.detail, boost }; } /** * @param {import('..').Variable} variable * @param {number} boost * * @returns {import('@codemirror/autocomplete').Completion} */ function createFunctionVariable(variable, boost) { const { name, info, detail, params = [] } = variable; const paramsWithNames = params.map(({ name, type }, index) => ({ name: name || `param ${index + 1}`, type })); const template = `${name}(${paramsWithNames.map(p => '${' + p.name + '}').join(', ')})`; const paramsSignature = paramsWithNames.map(({ name, type }) => ( type ? `${name}: ${type}` : name )).join(', '); const label = `${name}(${paramsSignature})`; return autocomplete.snippetCompletion(template, { label, type: 'function', info, detail, boost }); } /** * @typedef { import('../core').Variable } Variable * @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource */ /** * @param { { * variables?: Variable[], * builtins?: Variable[] * } } options * * @return { CompletionSource[] } */ function completions({ variables = [], builtins = [] }) { return [ pathExpressionCompletion({ variables }), variableCompletion({ variables, builtins }), langFeel.snippets, ...langFeel.keywordCompletions ]; } /** * @typedef { 'expression' | 'unaryTests' } Dialect */ /** * @typedef { 'camunda' | undefined } ParserDialect */ /** * @param { { * dialect?: Dialect, * parserDialect?: ParserDialect, * context?: Record<string, any>, * completions?: import('@codemirror/autocomplete').CompletionSource[] * } } options * * @return { import('@codemirror/language').LanguageSupport } */ function language(options) { return langFeel.feel(options); } /** * @param { import('../core').Variable[] } variables * * @return {Record<string, any>} */ function createContext(variables) { return variables.slice().reverse().reduce((context, builtin) => { context[builtin.name] = () => {}; return context; }, {}); } /** * @typedef { import('../language').Dialect } Dialect * @typedef { import('../language').ParserDialect } ParserDialect * @typedef { import('..').Variable } Variable */ /** * @type {Facet<Variable[]>} */ const builtinsFacet = state.Facet.define(); /** * @type {Facet<Variable[]>} */ const variablesFacet = state.Facet.define(); /** * @type {Facet<Dialect>} */ const dialectFacet = state.Facet.define(); /** * @type {Facet<ParserDialect>} */ const parserDialectFacet = state.Facet.define(); /** * @typedef {object} Variable * @property {string} name name or key of the variable * @property {string} [info] short information about the variable, e.g. type * @property {string} [detail] longer description of the variable content * @property {boolean} [isList] whether the variable is a list * @property {Array<Variable>} [schema] array of child variables if the variable is a context or list * @property {'function'|'variable'} [type] type of the variable * @property {Array<{name: string, type: string}>} [params] function parameters */ /** * @typedef { { * dialect?: import('../language').Dialect, * parserDialect?: import('../language').ParserDialect, * variables?: Variable[], * builtins?: Variable[] * } } CoreConfig * * @typedef { import('@codemirror/autocomplete').CompletionSource } CompletionSource * @typedef { import('@codemirror/state').Extension } Extension */ /** * @param { CoreConfig & { completions?: CompletionSource[] } } config * * @return { Extension } */ function configure({ dialect = 'expression', parserDialect, variables = [], builtins = [], completions: completions$1 = completions({ builtins, variables }) }) { const context = createContext([ ...variables, ...builtins ]); return [ dialectFacet.of(dialect), builtinsFacet.of(builtins), variablesFacet.of(variables), parserDialectFacet.of(parserDialect), language({ dialect, parserDialect, context, completions: completions$1 }) ]; } /** * @param {import('@codemirror/state').EditorState } state * * @return { CoreConfig } */ function get(state) { const builtins = state.facet(builtinsFacet)[0]; const variables = state.facet(variablesFacet)[0]; const dialect = state.facet(dialectFacet)[0]; const parserDialect = state.facet(parserDialectFacet)[0]; return { builtins, variables, dialect, parserDialect }; } const domifiedBuiltins = feelBuiltins.camundaBuiltins.map(builtin => ({ ...builtin, info: () => minDom.domify(builtin.info), })); /** * @typedef { import('./core').Variable } Variable */ /** * @typedef { import('./language').Dialect } Dialect * @typedef { import('./language').ParserDialect } ParserDialect */ const coreConf = new state.Compartment(); const placeholderConf = new state.Compartment(); /** * Creates a FEEL editor in the supplied container * * @param {Object} config * @param {DOMNode} config.container * @param {Extension[]} [config.extensions] * @param {Dialect} [config.dialect='expression'] * @param {ParserDialect} [config.parserDialect] * @param {DOMNode|String} [config.tooltipContainer] * @param {Function} [config.onChange] * @param {Function} [config.onKeyDown] * @param {Function} [config.onLint] * @param {Boolean} [config.readOnly] * @param {String} [config.value] * @param {Variable[]} [config.variables] * @param {Variable[]} [config.builtins] */ function FeelEditor({ extensions: editorExtensions = [], dialect = 'expression', parserDialect, container, contentAttributes = {}, tooltipContainer, onChange = () => {}, onKeyDown = () => {}, onLint = () => {}, placeholder = '', readOnly = false, value = '', builtins = domifiedBuiltins, variables = [] }) { const changeHandler = view.EditorView.updateListener.of((update) => { if (update.docChanged) { onChange(update.state.doc.toString()); } }); const lintHandler = view.EditorView.updateListener.of((update) => { const diagnosticEffects = update.transactions .flatMap(t => t.effects) .filter(effect => effect.is(lint.setDiagnosticsEffect)); if (!diagnosticEffects.length) { return; } const messages = diagnosticEffects.flatMap(effect => effect.value); onLint(messages); }); const keyHandler = view.EditorView.domEventHandlers( { keydown: onKeyDown } ); if (typeof tooltipContainer === 'string') { tooltipContainer = document.querySelector(tooltipContainer); } const tooltipLayout = tooltipContainer ? view.tooltips({ tooltipSpace: function() { return tooltipContainer.getBoundingClientRect(); } }) : []; const extensions = [ autocomplete.autocompletion(), coreConf.of(configure({ dialect, builtins, variables, parserDialect })), language$1.bracketMatching(), language$1.indentOnInput(), autocomplete.closeBrackets(), view.EditorView.contentAttributes.of(contentAttributes), changeHandler, keyHandler, view.keymap.of([ ...commands.defaultKeymap, ]), linter, lintHandler, tooltipLayout, placeholderConf.of(view.placeholder(placeholder)), theme, ...editorExtensions ]; if (readOnly) { extensions.push(view.EditorView.editable.of(false)); } this._cmEditor = new view.EditorView({ state: state.EditorState.create({ doc: value, extensions }), parent: container }); return this; } /** * Replaces the content of the Editor * * @param {String} value */ FeelEditor.prototype.setValue = function(value) { this._cmEditor.dispatch({ changes: { from: 0, to: this._cmEditor.state.doc.length, insert: value, } }); }; /** * Sets the focus in the editor. */ FeelEditor.prototype.focus = function(position) { const cmEditor = this._cmEditor; // the Codemirror `focus` method always calls `focus` with `preventScroll`, // so we have to focus + scroll manually cmEditor.contentDOM.focus(); cmEditor.focus(); if (typeof position === 'number') { const end = cmEditor.state.doc.length; cmEditor.dispatch({ selection: { anchor: position <= end ? position : end } }); } }; /** * Returns the current selection ranges. If no text is selected, a single * range with the start and end index at the cursor position will be returned. * * @returns {Object} selection * @returns {Array} selection.ranges */ FeelEditor.prototype.getSelection = function() { return this._cmEditor.state.selection; }; /** * Set variables to be used for autocompletion. * * @param {Variable[]} variables */ FeelEditor.prototype.setVariables = function(variables) { const config = get(this._cmEditor.state); this._cmEditor.dispatch({ effects: [ coreConf.reconfigure(configure({ ...config, variables })) ] }); }; /** * Update placeholder text. * * @param {string} placeholder */ FeelEditor.prototype.setPlaceholder = function(placeholder) { this._cmEditor.dispatch({ effects: placeholderConf.reconfigure(view.placeholder(placeholder)) }); }; module.exports = FeelEditor;