diff --git a/frontend/discourse/app/components/d-modal.gjs b/frontend/discourse/app/components/d-modal.gjs index b511bf3603d9a..a24d8673d1a2f 100644 --- a/frontend/discourse/app/components/d-modal.gjs +++ b/frontend/discourse/app/components/d-modal.gjs @@ -201,7 +201,7 @@ export default class DModal extends Component { event.preventDefault(); } - if (event.key === "Escape" && this.dismissable) { + if (event.key === "Escape" && this.dismissable && !event.defaultPrevented) { event.stopPropagation(); this.closeModal(CLOSE_INITIATED_BY_ESC); } diff --git a/frontend/discourse/app/lib/load-codemirror.js b/frontend/discourse/app/lib/load-codemirror.js new file mode 100644 index 0000000000000..1cd30f208ddbd --- /dev/null +++ b/frontend/discourse/app/lib/load-codemirror.js @@ -0,0 +1,9 @@ +import { waitForPromise } from "@ember/test-waiters"; + +export default async function loadCodemirrorEditor() { + return ( + await waitForPromise( + import("discourse/static/codemirror/components/codemirror-editor") + ) + ).default; +} diff --git a/frontend/discourse/app/static/codemirror/build-extensions.js b/frontend/discourse/app/static/codemirror/build-extensions.js new file mode 100644 index 0000000000000..c547575063268 --- /dev/null +++ b/frontend/discourse/app/static/codemirror/build-extensions.js @@ -0,0 +1,17 @@ +import * as cmAutocomplete from "@codemirror/autocomplete"; +import * as cmLanguage from "@codemirror/language"; +import * as cmState from "@codemirror/state"; +import * as cmView from "@codemirror/view"; +import * as lezerHighlight from "@lezer/highlight"; +import { expressionUtils } from "./expression-utils"; + +export function buildCmParams() { + return { + cmAutocomplete, + cmLanguage, + cmState, + cmView, + lezerHighlight, + utils: expressionUtils, + }; +} diff --git a/frontend/discourse/app/static/codemirror/completions-data.js b/frontend/discourse/app/static/codemirror/completions-data.js new file mode 100644 index 0000000000000..17c7667165d08 --- /dev/null +++ b/frontend/discourse/app/static/codemirror/completions-data.js @@ -0,0 +1,577 @@ +function sectionHeader(name) { + return () => { + const header = document.createElement("div"); + header.className = "cm-expr-section-header"; + header.textContent = name; + return header; + }; +} + +export function section(name, rank) { + return { name, rank, header: sectionHeader(name) }; +} + +export const SECTION_RECOMMENDED = section("Recommended", 0); +export const SECTION_PROPERTIES = section("Properties", 2); +export const SECTION_METHODS = section("Methods", 3); +export const SECTION_METADATA = section("Metadata", 4); +export const SECTION_GLOBALS = section("Globals", 5); + +export function info(signature, description, example) { + return () => { + const div = document.createElement("div"); + div.className = "cm-expr-completion-info"; + + const sig = document.createElement("div"); + sig.className = "cm-expr-completion-info__signature"; + sig.textContent = signature; + div.appendChild(sig); + + const desc = document.createElement("div"); + desc.className = "cm-expr-completion-info__description"; + desc.textContent = description; + div.appendChild(desc); + + if (example) { + const ex = document.createElement("div"); + ex.className = "cm-expr-completion-info__example"; + const code = document.createElement("code"); + code.textContent = example; + ex.appendChild(code); + div.appendChild(ex); + } + + return div; + }; +} + +export const STRING_METHODS = [ + { + label: "length", + type: "property", + detail: "number", + info: info("str.length", "The number of characters in the string."), + }, + { + label: "includes", + type: "method", + detail: "(str) => boolean", + info: info( + "str.includes(searchString, position?)", + "Returns true if the string contains the substring.", + '"hello".includes("ell") → true' + ), + }, + { + label: "startsWith", + type: "method", + detail: "(str) => boolean", + info: info( + "str.startsWith(searchString, position?)", + "Returns true if the string starts with the given characters." + ), + }, + { + label: "endsWith", + type: "method", + detail: "(str) => boolean", + info: info( + "str.endsWith(searchString, length?)", + "Returns true if the string ends with the given characters." + ), + }, + { + label: "split", + type: "method", + detail: "(sep) => string[]", + info: info( + "str.split(separator, limit?)", + "Splits the string into an array of substrings.", + '"a,b,c".split(",") → ["a","b","c"]' + ), + }, + { + label: "replaceAll", + type: "method", + detail: "(search, replace) => string", + info: info( + "str.replaceAll(search, replacement)", + "Replaces all occurrences of a search string." + ), + }, + { + label: "replace", + type: "method", + detail: "(search, replace) => string", + info: info( + "str.replace(search, replacement)", + "Replaces the first occurrence of a search string." + ), + }, + { + label: "trim", + type: "method", + detail: "() => string", + info: info( + "str.trim()", + "Removes whitespace from both ends of the string." + ), + }, + { + label: "trimStart", + type: "method", + detail: "() => string", + info: info( + "str.trimStart()", + "Removes whitespace from the beginning of the string." + ), + }, + { + label: "trimEnd", + type: "method", + detail: "() => string", + info: info( + "str.trimEnd()", + "Removes whitespace from the end of the string." + ), + }, + { + label: "toLowerCase", + type: "method", + detail: "() => string", + info: info("str.toLowerCase()", "Converts the string to lowercase."), + }, + { + label: "toUpperCase", + type: "method", + detail: "() => string", + info: info("str.toUpperCase()", "Converts the string to uppercase."), + }, + { + label: "slice", + type: "method", + detail: "(start, end?) => string", + info: info( + "str.slice(start, end?)", + "Extracts a section of the string.", + '"hello".slice(1, 4) → "ell"' + ), + }, + { + label: "substring", + type: "method", + detail: "(start, end?) => string", + info: info( + "str.substring(start, end?)", + "Returns the part of the string between two indices." + ), + }, + { + label: "indexOf", + type: "method", + detail: "(str) => number", + info: info( + "str.indexOf(searchValue, fromIndex?)", + "Returns the index of the first occurrence, or -1 if not found." + ), + }, + { + label: "match", + type: "method", + detail: "(regex) => array", + info: info( + "str.match(regexp)", + "Matches the string against a regular expression." + ), + }, + { + label: "concat", + type: "method", + detail: "(...strings) => string", + info: info("str.concat(...strings)", "Concatenates one or more strings."), + }, +]; + +export const NUMBER_METHODS = [ + { + label: "toFixed", + type: "method", + detail: "(digits?) => string", + info: info( + "num.toFixed(digits?)", + "Formats the number with a fixed number of decimal places.", + '(3.14159).toFixed(2) → "3.14"' + ), + }, + { + label: "toString", + type: "method", + detail: "(radix?) => string", + info: info( + "num.toString(radix?)", + "Returns a string representation of the number." + ), + }, + { + label: "toPrecision", + type: "method", + detail: "(precision?) => string", + info: info( + "num.toPrecision(precision?)", + "Formats the number to a specified precision." + ), + }, + { + label: "toLocaleString", + type: "method", + detail: "() => string", + info: info( + "num.toLocaleString(locales?, options?)", + "Returns a locale-sensitive string representation." + ), + }, +]; + +export const BOOLEAN_METHODS = [ + { + label: "toString", + type: "method", + detail: "() => string", + info: info( + "bool.toString()", + "Converts true to 'true' and false to 'false'." + ), + }, +]; + +export const ARRAY_METHODS = [ + { + label: "length", + type: "property", + detail: "number", + info: info("arr.length", "The number of elements in the array."), + }, + { + label: "map", + type: "method", + detail: "(fn) => array", + info: info( + "arr.map(callback)", + "Creates a new array with the results of calling a function on every element.", + "[1,2,3].map(x => x * 2) → [2,4,6]" + ), + }, + { + label: "filter", + type: "method", + detail: "(fn) => array", + info: info( + "arr.filter(callback)", + "Creates a new array with elements that pass the test.", + "[1,2,3,4].filter(x => x > 2) → [3,4]" + ), + }, + { + label: "find", + type: "method", + detail: "(fn) => item", + info: info( + "arr.find(callback)", + "Returns the first element that satisfies the testing function." + ), + }, + { + label: "includes", + type: "method", + detail: "(item) => boolean", + info: info( + "arr.includes(value, fromIndex?)", + "Returns true if the array contains the specified value." + ), + }, + { + label: "join", + type: "method", + detail: "(sep?) => string", + info: info( + "arr.join(separator?)", + "Joins all elements into a string.", + '["a","b","c"].join("-") → "a-b-c"' + ), + }, + { + label: "slice", + type: "method", + detail: "(start, end?) => array", + info: info( + "arr.slice(start?, end?)", + "Returns a shallow copy of a portion of the array." + ), + }, + { + label: "sort", + type: "method", + detail: "(fn?) => array", + info: info( + "arr.sort(compareFn?)", + "Sorts the elements of the array in place." + ), + }, + { + label: "reverse", + type: "method", + detail: "() => array", + info: info("arr.reverse()", "Reverses the order of elements."), + }, + { + label: "reduce", + type: "method", + detail: "(fn, init?) => any", + info: info( + "arr.reduce(callback, initialValue?)", + "Reduces the array to a single value.", + "[1,2,3].reduce((sum, x) => sum + x, 0) → 6" + ), + }, + { + label: "indexOf", + type: "method", + detail: "(item) => number", + info: info( + "arr.indexOf(value, fromIndex?)", + "Returns the first index of the value, or -1 if not found." + ), + }, + { + label: "concat", + type: "method", + detail: "(...arrays) => array", + info: info( + "arr.concat(...values)", + "Merges arrays and/or values into a new array." + ), + }, +]; + +export const DATE_METHODS = [ + { + label: "toISOString", + type: "method", + detail: "() => string", + info: info("date.toISOString()", "Returns the date as an ISO 8601 string."), + }, + { + label: "toLocaleDateString", + type: "method", + detail: "() => string", + info: info( + "date.toLocaleDateString(locales?, options?)", + "Returns a locale-sensitive date string." + ), + }, + { + label: "getTime", + type: "method", + detail: "() => number", + info: info("date.getTime()", "Returns milliseconds since Unix epoch."), + }, + { + label: "getFullYear", + type: "method", + detail: "() => number", + info: info("date.getFullYear()", "Returns the four-digit year."), + }, + { + label: "getMonth", + type: "method", + detail: "() => number", + info: info("date.getMonth()", "Returns the month (0-11)."), + }, + { + label: "getDate", + type: "method", + detail: "() => number", + info: info("date.getDate()", "Returns the day of the month (1-31)."), + }, + { + label: "getHours", + type: "method", + detail: "() => number", + info: info("date.getHours()", "Returns the hour (0-23)."), + }, + { + label: "getMinutes", + type: "method", + detail: "() => number", + info: info("date.getMinutes()", "Returns the minutes (0-59)."), + }, + { + label: "toLocaleString", + type: "method", + detail: "() => string", + info: info( + "date.toLocaleString(locales?, options?)", + "Returns a locale-sensitive date and time string." + ), + }, +]; + +export const GLOBAL_COMPLETIONS = [ + { + label: "Math", + type: "variable", + detail: "object", + info: "Mathematical constants and functions (Math.round, Math.random, etc.)", + }, + { + label: "JSON", + type: "variable", + detail: "object", + info: "JSON parsing and serialization (JSON.parse, JSON.stringify).", + }, + { + label: "Object", + type: "variable", + detail: "object", + info: "Object utilities (Object.keys, Object.values, Object.entries).", + }, + { + label: "Array", + type: "variable", + detail: "object", + info: "Array utilities (Array.isArray, Array.from).", + }, + { + label: "String", + type: "variable", + detail: "object", + info: "String constructor and utilities.", + }, + { + label: "Number", + type: "variable", + detail: "object", + info: "Number utilities (Number.isInteger, Number.parseFloat).", + }, + { + label: "Date", + type: "variable", + detail: "object", + info: "Date constructor. Use new Date() to create dates.", + }, + { + label: "parseInt", + type: "function", + detail: "(string, radix?) => number", + info: "Parses a string and returns an integer.", + }, + { + label: "parseFloat", + type: "function", + detail: "(string) => number", + info: "Parses a string and returns a floating-point number.", + }, + { + label: "encodeURIComponent", + type: "function", + detail: "(string) => string", + info: "Encodes a URI component by replacing special characters.", + }, + { + label: "decodeURIComponent", + type: "function", + detail: "(string) => string", + info: "Decodes an encoded URI component.", + }, + { + label: "isNaN", + type: "function", + detail: "(value) => boolean", + info: "Returns true if the value is NaN.", + }, + { + label: "isFinite", + type: "function", + detail: "(value) => boolean", + info: "Returns true if the value is a finite number.", + }, +]; + +// Non-enumerable, so Object.keys won't find them — listed explicitly. +export const GLOBAL_STATIC_METHODS = { + Math: [ + { label: "abs", type: "function", detail: "(x) => number" }, + { label: "ceil", type: "function", detail: "(x) => number" }, + { label: "floor", type: "function", detail: "(x) => number" }, + { label: "round", type: "function", detail: "(x) => number" }, + { label: "max", type: "function", detail: "(...values) => number" }, + { label: "min", type: "function", detail: "(...values) => number" }, + { label: "random", type: "function", detail: "() => number" }, + { label: "pow", type: "function", detail: "(base, exp) => number" }, + { label: "sqrt", type: "function", detail: "(x) => number" }, + { label: "trunc", type: "function", detail: "(x) => number" }, + { label: "PI", type: "property", detail: "number" }, + ], + JSON: [ + { label: "parse", type: "function", detail: "(text) => any" }, + { label: "stringify", type: "function", detail: "(value) => string" }, + ], + Object: [ + { label: "keys", type: "function", detail: "(obj) => string[]" }, + { label: "values", type: "function", detail: "(obj) => any[]" }, + { label: "entries", type: "function", detail: "(obj) => [string, any][]" }, + { + label: "assign", + type: "function", + detail: "(target, ...sources) => object", + }, + { label: "fromEntries", type: "function", detail: "(iterable) => object" }, + ], + Array: [ + { label: "isArray", type: "function", detail: "(value) => boolean" }, + { label: "from", type: "function", detail: "(iterable) => array" }, + ], + Number: [ + { label: "isInteger", type: "function", detail: "(value) => boolean" }, + { label: "isFinite", type: "function", detail: "(value) => boolean" }, + { label: "isNaN", type: "function", detail: "(value) => boolean" }, + { label: "parseInt", type: "function", detail: "(string) => number" }, + { label: "parseFloat", type: "function", detail: "(string) => number" }, + ], + String: [ + { label: "fromCharCode", type: "function", detail: "(...codes) => string" }, + ], + Date: [ + { label: "now", type: "function", detail: "() => number" }, + { label: "parse", type: "function", detail: "(string) => number" }, + ], +}; + +function buildMethodMap(methods) { + return new Map(methods.map((m) => [m.label, m])); +} + +const METHOD_DOCS_BY_TYPE = { + string: buildMethodMap(STRING_METHODS), + number: buildMethodMap(NUMBER_METHODS), + boolean: buildMethodMap(BOOLEAN_METHODS), + array: buildMethodMap(ARRAY_METHODS), + date: buildMethodMap(DATE_METHODS), +}; + +export function lookupMethodDoc(name, parentValue) { + if (parentValue === null || parentValue === undefined) { + return null; + } + let typeName; + if (Array.isArray(parentValue)) { + typeName = "array"; + } else if (parentValue instanceof Date) { + typeName = "date"; + } else { + typeName = typeof parentValue; + } + return METHOD_DOCS_BY_TYPE[typeName]?.get(name) ?? null; +} + +export const GLOBAL_DOCS = new Map(GLOBAL_COMPLETIONS.map((g) => [g.label, g])); diff --git a/frontend/discourse/app/static/codemirror/components/codemirror-editor.gjs b/frontend/discourse/app/static/codemirror/components/codemirror-editor.gjs new file mode 100644 index 0000000000000..cbcd67723950e --- /dev/null +++ b/frontend/discourse/app/static/codemirror/components/codemirror-editor.gjs @@ -0,0 +1,162 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; +import { closeCompletion, completionStatus } from "@codemirror/autocomplete"; +import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { + EditorView, + keymap, + lineNumbers, + placeholder, + ViewPlugin, +} from "@codemirror/view"; +import { bind } from "discourse/lib/decorators"; +import { buildCmParams } from "../build-extensions"; + +export default class CodemirrorEditor extends Component { + @tracked view = null; + #lastValue; + #suppressChange = false; + + @action + setup(container) { + const extensions = [ + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const value = update.state.doc.toString(); + this.#lastValue = value; + if (!this.#suppressChange) { + this.args.change?.(value); + } + } + if (update.focusChanged) { + if (update.view.hasFocus) { + this.args.focusIn?.(); + } else { + this.args.focusOut?.(); + } + } + }), + ]; + + if (this.args.placeholder) { + extensions.push(placeholder(this.args.placeholder)); + } + + if (this.args.lineNumbers) { + extensions.push(lineNumbers()); + } + + if (this.args.lineWrapping) { + extensions.push(EditorView.lineWrapping); + } + + if (this.args.readOnly) { + extensions.push( + EditorState.readOnly.of(true), + EditorView.editable.of(false) + ); + } + + if (this.args.singleLine) { + extensions.push( + EditorState.transactionFilter.of((tr) => { + if (tr.newDoc.lines > 1) { + return []; + } + return tr; + }) + ); + } + + extensions.push( + ViewPlugin.fromClass( + class { + constructor(view) { + this.view = view; + this.handler = (event) => { + if (event.key !== "Escape" || !this.view.hasFocus) { + return; + } + if (completionStatus(this.view.state)) { + closeCompletion(this.view); + } else { + this.view.contentDOM.blur(); + } + event.preventDefault(); + }; + window.addEventListener("keydown", this.handler, { capture: true }); + } + + destroy() { + window.removeEventListener("keydown", this.handler, { + capture: true, + }); + } + } + ) + ); + + if (this.args.extensions) { + extensions.push(...this.args.extensions(buildCmParams())); + } + + const initialValue = this.args.value ?? ""; + + this.view = new EditorView({ + parent: container, + state: EditorState.create({ + doc: initialValue, + extensions, + }), + }); + + this.#lastValue = initialValue; + + this.args.onSetup?.(this.view); + } + + @bind + updateValue() { + if (!this.view) { + return; + } + + const value = this.args.value ?? ""; + if (value === this.#lastValue) { + return; + } + + this.#suppressChange = true; + this.view.dispatch({ + changes: { + from: 0, + to: this.view.state.doc.length, + insert: value, + }, + }); + this.#lastValue = value; + this.#suppressChange = false; + } + + @action + teardown() { + this.view?.destroy(); + this.view = null; + } + + +} diff --git a/frontend/discourse/app/static/codemirror/expression-utils.js b/frontend/discourse/app/static/codemirror/expression-utils.js new file mode 100644 index 0000000000000..e18d083de1211 --- /dev/null +++ b/frontend/discourse/app/static/codemirror/expression-utils.js @@ -0,0 +1,70 @@ +import { + ARRAY_METHODS, + BOOLEAN_METHODS, + DATE_METHODS, + GLOBAL_COMPLETIONS, + GLOBAL_DOCS, + GLOBAL_STATIC_METHODS, + lookupMethodDoc, + NUMBER_METHODS, + section, + SECTION_GLOBALS, + SECTION_METADATA, + SECTION_METHODS, + SECTION_PROPERTIES, + SECTION_RECOMMENDED, + STRING_METHODS, +} from "./completions-data"; +import { expressionLanguage } from "./lang-expression/index"; +import { + analyzePropertyAccess, + isInsideExpression, + isInsideExpressionAt, + resolveNodeValue, +} from "./tree-utils"; + +function methodsForType(value) { + if (value === null || value === undefined) { + return []; + } + if (Array.isArray(value)) { + return ARRAY_METHODS; + } + if (value instanceof Date) { + return DATE_METHODS; + } + switch (typeof value) { + case "string": + return STRING_METHODS; + case "number": + return NUMBER_METHODS; + case "boolean": + return BOOLEAN_METHODS; + default: + return []; + } +} + +// Generic expression utilities for {{ }} editors. Domain-specific +// analysis (like workflow $() node refs) should extend +// analyzePropertyAccess in the consumer, not here. +export const expressionUtils = { + expressionLanguage, + analyzePropertyAccess, + resolveNodeValue, + isInsideExpression, + isInsideExpressionAt, + lookupMethodDoc, + methodsForType, + section, + globalDocs: GLOBAL_DOCS, + sections: { + recommended: SECTION_RECOMMENDED, + properties: SECTION_PROPERTIES, + methods: SECTION_METHODS, + metadata: SECTION_METADATA, + globals: SECTION_GLOBALS, + }, + globalCompletions: GLOBAL_COMPLETIONS, + globalStaticMethods: GLOBAL_STATIC_METHODS, +}; diff --git a/frontend/discourse/app/static/codemirror/lang-expression/expression.grammar b/frontend/discourse/app/static/codemirror/lang-expression/expression.grammar new file mode 100644 index 0000000000000..d09e45208da5f --- /dev/null +++ b/frontend/discourse/app/static/codemirror/lang-expression/expression.grammar @@ -0,0 +1,15 @@ +@top Template { entity* } + +entity { Text | Expression } + +Expression { OpenExpression expressionContent* CloseExpression } + +@tokens { + Text { ![{] Text? | "{" (@eof | ![{] Text?) } + OpenExpression[closedBy="CloseExpression"] { "{{" } + CloseExpression[openedBy="OpenExpression"] { "}}" } + expressionContent { unicodeChar | "}" ![}] | "\\}}" } + unicodeChar { $[\u0000-\u007C] | $[\u007E-\u{10FFFF}] } +} + +@detectDelim diff --git a/frontend/discourse/app/static/codemirror/lang-expression/expression.js b/frontend/discourse/app/static/codemirror/lang-expression/expression.js new file mode 100644 index 0000000000000..9124e62fe790a --- /dev/null +++ b/frontend/discourse/app/static/codemirror/lang-expression/expression.js @@ -0,0 +1,23 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import { LRParser } from "@lezer/lr"; + +export const parser = LRParser.deserialize({ + version: 14, + states: + "!dQQOPOOOYOQO'#CaOOOO'#Ce'#CeOOOO'#Cb'#CbQQOPOOOOOO'#Cc'#CcObOQO,58{OOOO,58{,58{OOOO-E6`-E6`OOOO-E6a-E6aOOOO1G.g1G.g", + stateData: "j~OQQOSPO~ORVOYTO~ORYOYTO~O", + goto: "oYPPPPPZ_ePkTQOSQSORWSQUPRXUTROS", + nodeNames: "⚠ Template Text CloseExpression OpenExpression Expression", + maxTerm: 10, + nodeProps: [ + ["openedBy", 3, "OpenExpression"], + ["closedBy", 4, "CloseExpression"], + ], + skippedNodes: [0], + repeatNodeCount: 2, + tokenData: + "&tRRXO#On#O#P#[#P#on#o#p$d#p#qn#q#r%T#r;'Sn;'S;=`&n<%lOnRuTQPYQO#o!U#o#p!j#p;'S!U;'S;=`#P<%lO!UP!ZTQPO#o!U#o#p!j#p;'S!U;'S;=`#P<%lO!UP!mUO#o!U#p;'S!U;'S;=`#P<%l~!U~O!U~~#VP#SP;=`<%l!UP#[OQPR#cVQPYQO#o!U#o#p!j#p#q!U#q#r#x#r;'S!U;'S;=`#P<%lO!UR#}VQPO#o!U#o#p!j#p#q!U#q#rn#r;'S!U;'S;=`#P<%lO!UR$iVYQO#o!U#o#p%O#p;'S!U;'S;=`#P<%l~!U~O!U~~#VP%TOSPR%YVQPO#on#o#p%o#p#qn#q#r&W#r;'Sn;'S;=`&n<%lOnR%tUYQO#o!U#p;'S!U;'S;=`#P<%l~!U~O!U~~#VR&_TRQQPO#o!U#o#p!j#p;'S!U;'S;=`#P<%lO!UR&qP;=`<%ln", + tokenizers: [0, 1], + topRules: { Template: [0, 1] }, + tokenPrec: 0, +}); diff --git a/frontend/discourse/app/static/codemirror/lang-expression/expression.terms.js b/frontend/discourse/app/static/codemirror/lang-expression/expression.terms.js new file mode 100644 index 0000000000000..aa18dd0752fa7 --- /dev/null +++ b/frontend/discourse/app/static/codemirror/lang-expression/expression.terms.js @@ -0,0 +1,6 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const Template = 1, + Text = 2, + CloseExpression = 3, + OpenExpression = 4, + Expression = 5; diff --git a/frontend/discourse/app/static/codemirror/lang-expression/index.js b/frontend/discourse/app/static/codemirror/lang-expression/index.js new file mode 100644 index 0000000000000..99ec0ecca9a8c --- /dev/null +++ b/frontend/discourse/app/static/codemirror/lang-expression/index.js @@ -0,0 +1,57 @@ +import { javascriptLanguage } from "@codemirror/lang-javascript"; +import { LanguageSupport, LRLanguage } from "@codemirror/language"; +import { parseMixed } from "@lezer/common"; +import { styleTags, tags as t } from "@lezer/highlight"; +import { parser } from "./expression"; +import { + CloseExpression, + Expression, + OpenExpression, +} from "./expression.terms"; + +const mixedParser = parser.configure({ + props: [ + styleTags({ + Text: t.content, + Expression: t.string, + "OpenExpression CloseExpression": t.brace, + }), + ], + wrap: parseMixed((node) => { + if (node.type.id === Expression) { + let from = node.from; + let to = node.to; + + const first = node.node.firstChild; + if (first?.type.id === OpenExpression) { + from = first.to; + } + const last = node.node.lastChild; + if (last?.type.id === CloseExpression) { + to = last.from; + } + + if (from >= to) { + return null; + } + return { + parser: javascriptLanguage.parser, + overlay: [{ from, to }], + }; + } + return null; + }), +}); + +const expressionLang = LRLanguage.define({ + name: "expression", + parser: mixedParser, + languageData: { + closeBrackets: { brackets: ["(", "[", "'", '"'] }, + commentTokens: { line: "//" }, + }, +}); + +export function expressionLanguage() { + return new LanguageSupport(expressionLang); +} diff --git a/frontend/discourse/app/static/codemirror/tree-utils.js b/frontend/discourse/app/static/codemirror/tree-utils.js new file mode 100644 index 0000000000000..4142c23f13d5d --- /dev/null +++ b/frontend/discourse/app/static/codemirror/tree-utils.js @@ -0,0 +1,94 @@ +import { syntaxTree } from "@codemirror/language"; + +function findDot(memberExpr) { + for (let child = memberExpr.lastChild; child; child = child.prevSibling) { + if (child.name === ".") { + return child.from; + } + } + return null; +} + +export function resolveNodeValue(node, doc) { + if (!node) { + return null; + } + + if (node.name === "VariableName" || node.name === "VariableDefinition") { + return doc.sliceString(node.from, node.to); + } + + if (node.name === "MemberExpression") { + const obj = resolveNodeValue(node.firstChild, doc); + const prop = node.lastChild; + if (obj && prop && prop.name === "PropertyName") { + return `${obj}.${doc.sliceString(prop.from, prop.to)}`; + } + return obj; + } + + if (node.name === "CallExpression") { + const callee = node.firstChild; + if (callee) { + const calleeName = doc.sliceString(callee.from, callee.to); + const argList = node.getChild("ArgList"); + if (argList) { + const strNode = argList.getChild("String"); + if (strNode) { + const raw = doc.sliceString(strNode.from, strNode.to); + return `${calleeName}(${raw})`; + } + } + } + } + + return null; +} + +export function analyzePropertyAccess(state, pos) { + const tree = syntaxTree(state); + const node = tree.resolveInner(pos, -1); + const doc = state.doc; + + let cur = node; + while (cur) { + if (cur.name === "MemberExpression") { + const dotPos = findDot(cur); + if (dotPos !== null && pos > dotPos) { + const obj = resolveNodeValue(cur.firstChild, doc); + const partial = doc.sliceString(dotPos + 1, pos); + return { kind: "property", object: obj, partial, from: dotPos + 1 }; + } + } + + if (cur.name === "MemberExpression" && cur.getChild("[")) { + const obj = resolveNodeValue(cur.firstChild, doc); + return { kind: "bracket", object: obj, from: pos }; + } + + cur = cur.parent; + } + + if (node.name === "VariableName" || node.name === "VariableDefinition") { + const text = doc.sliceString(node.from, pos); + return { kind: "identifier", partial: text, from: node.from }; + } + + return { kind: "blank", from: pos }; +} + +export function isInsideExpressionAt(state, pos) { + const tree = syntaxTree(state); + let node = tree.resolve(pos, -1); + while (node) { + if (node.name === "Expression") { + return true; + } + node = node.parent; + } + return false; +} + +export function isInsideExpression(context) { + return isInsideExpressionAt(context.state, context.pos); +} diff --git a/frontend/discourse/package.json b/frontend/discourse/package.json index 9323833d0637e..a7cf05b57ca3f 100644 --- a/frontend/discourse/package.json +++ b/frontend/discourse/package.json @@ -25,6 +25,12 @@ "test": "ember test" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@faker-js/faker": "^10.4.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", @@ -39,10 +45,15 @@ "@json-editor/json-editor": "2.15.2", "@jsquash/jpeg": "^1.6.0", "@jsquash/resize": "^2.1.0", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", + "@lezer/javascript": "^1.5.4", + "@lezer/lr": "^1.4.8", "ace-builds": "^1.43.6", "chart.js": "4.5.1", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "2.2.0", + "codemirror": "^6.0.2", "decorator-transforms": "^2.3.1", "diff": "^8.0.4", "elkjs": "^0.11.1", diff --git a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-input.gjs b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-input.gjs index 50fd163a28a07..992f6850165ab 100644 --- a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-input.gjs +++ b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-input.gjs @@ -1,8 +1,32 @@ import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { cancel } from "@ember/runloop"; +import { service } from "@ember/service"; +import discourseLater from "discourse/lib/later"; +import buildWorkflowExtension from "../../../lib/workflows/codemirror-extension"; +import { + resolveAllAncestors, + resolvePreviousOutput, +} from "../../../lib/workflows/graph-traversal"; +import ExpressionPreview from "../variable/expression-preview"; import VariableInput from "../variable/input"; export default class ExpressionInput extends Component { + @service siteSettings; + @service workflowsNodeTypes; + + @tracked isFocused = false; + @tracked segments = []; + wrapperElement = null; + #focusOutTimer = null; + + willDestroy() { + super.willDestroy(...arguments); + cancel(this.#focusOutTimer); + } + get displayValue() { const val = this.args.field.value; if (typeof val === "string" && val.startsWith("=")) { @@ -11,16 +35,87 @@ export default class ExpressionInput extends Component { return val ?? ""; } + get triggerElement() { + return ( + this.wrapperElement?.querySelector(".workflows-variable-input") || + this.wrapperElement + ); + } + + @action + buildExtensions(cmParams) { + const node = this.workflowsNodeTypes.editingNode; + const graph = { + nodes: this.workflowsNodeTypes.graphNodes || [], + connections: this.workflowsNodeTypes.graphConnections || [], + nodeTypes: this.workflowsNodeTypes.nodeTypes || [], + }; + const itemPrefix = + this.workflowsNodeTypes.expressionContext.item_prefix || "$json"; + + return buildWorkflowExtension(cmParams, { + inputFields: node ? resolvePreviousOutput(node, graph) : [], + ancestorNodes: node ? resolveAllAncestors(node, graph) : [], + siteSettings: this.siteSettings, + workflowVars: this.workflowsNodeTypes.workflowVars, + nodes: graph.nodes, + itemPrefix, + workflowId: this.workflowsNodeTypes.workflowId, + nodeId: node?.id, + onSegmentsResolved: (segs) => (this.segments = segs), + }); + } + @action handleChange(value) { this.args.field.set(`=${value}`); } + @action + handleFocusIn() { + cancel(this.#focusOutTimer); + this.isFocused = true; + } + + @action + handleFocusOut() { + this.#focusOutTimer = discourseLater(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + const editor = this.wrapperElement?.querySelector(".cm-editor"); + if (editor?.contains(document.activeElement)) { + return; + } + const tooltip = document.querySelector( + '[data-identifier="expression-preview"]' + ); + if (tooltip?.contains(document.activeElement)) { + return; + } + this.isFocused = false; + }, 150); + } + + @action + registerWrapper(element) { + this.wrapperElement = element; + } + } diff --git a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-wrapper.gjs b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-wrapper.gjs index 81b545d49413a..6eddaee3c0f47 100644 --- a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-wrapper.gjs +++ b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/configurators/expression-wrapper.gjs @@ -6,6 +6,10 @@ import { service } from "@ember/service"; import DSegmentedControl from "discourse/components/d-segmented-control"; import concatClass from "discourse/helpers/concat-class"; import { i18n } from "discourse-i18n"; +import { + resolveVariableId, + WORKFLOW_VARIABLE_MIME, +} from "../../../lib/workflows/expression-context"; import { isExpression } from "../../../lib/workflows/property-engine"; import ExpressionInput from "./expression-input"; @@ -26,6 +30,15 @@ export default class ExpressionWrapper extends Component { @service workflowsNodeTypes; @tracked isDragOver = false; + _dragEndHandler = null; + + willDestroy() { + super.willDestroy(...arguments); + if (this._dragEndHandler) { + document.removeEventListener("dragend", this._dragEndHandler); + this._dragEndHandler = null; + } + } get expressionMode() { return isExpression(this.args.field?.value); @@ -53,13 +66,26 @@ export default class ExpressionWrapper extends Component { @action handleDragOver(event) { - if (!this.args.supportsExpression || this.expressionMode) { + if (!this.args.supportsExpression) { + return; + } + if (!event.dataTransfer.types.includes(WORKFLOW_VARIABLE_MIME)) { return; } - if (event.dataTransfer.types.includes("application/x-workflow-variable")) { - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - this.isDragOver = true; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + this.isDragOver = true; + + // Safety: clear on next dragend in case dragleave doesn't fire + if (!this._dragEndHandler) { + this._dragEndHandler = () => { + this.isDragOver = false; + document.removeEventListener("dragend", this._dragEndHandler); + this._dragEndHandler = null; + }; + document.addEventListener("dragend", this._dragEndHandler, { + once: true, + }); } } @@ -72,18 +98,19 @@ export default class ExpressionWrapper extends Component { @action handleDrop(event) { + this.isDragOver = false; + if (!this.args.supportsExpression || this.expressionMode) { return; } - const data = event.dataTransfer.getData("application/x-workflow-variable"); + const data = event.dataTransfer.getData(WORKFLOW_VARIABLE_MIME); if (!data) { return; } event.preventDefault(); event.stopPropagation(); - this.isDragOver = false; let variable; try { @@ -94,9 +121,7 @@ export default class ExpressionWrapper extends Component { const prefix = this.workflowsNodeTypes.expressionContext.item_prefix || "$json"; - const variableId = variable.id.startsWith("$") - ? variable.id - : `${prefix}.${variable.id}`; + const variableId = resolveVariableId(variable, prefix); this.args.field.set(`={{ ${variableId} }}`); } diff --git a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/context/schema-field.gjs b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/context/schema-field.gjs index 3f4bbc9aaecef..8535de4fd70a7 100644 --- a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/context/schema-field.gjs +++ b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/context/schema-field.gjs @@ -5,6 +5,7 @@ import { action } from "@ember/object"; import concatClass from "discourse/helpers/concat-class"; import icon from "discourse/helpers/d-icon"; import { and, not } from "discourse/truth-helpers"; +import { WORKFLOW_VARIABLE_MIME } from "../../../lib/workflows/expression-context"; export default class SchemaField extends Component { @tracked collapsed = true; @@ -40,7 +41,7 @@ export default class SchemaField extends Component { event.stopPropagation(); const field = this.args.field; event.dataTransfer.setData( - "application/x-workflow-variable", + WORKFLOW_VARIABLE_MIME, JSON.stringify({ id: this.fieldId, key: field.key, type: field.type }) ); event.dataTransfer.effectAllowed = "copy"; @@ -72,10 +73,7 @@ export default class SchemaField extends Component { diff --git a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/editor/index.gjs b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/editor/index.gjs index c763985ab40d7..1089e37d7e1a5 100644 --- a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/editor/index.gjs +++ b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/editor/index.gjs @@ -376,6 +376,10 @@ export default class WorkflowsEditor extends Component { const triggerNode = nodes.find((n) => n.type?.startsWith("trigger:")); + this.workflowsNodeTypes.setEditingContext(node, nodes, connections, { + workflowId: this.args.workflow?.id, + }); + this.modal.show(NodeConfigurator, { model: { node, diff --git a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/node/configurator.gjs b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/node/configurator.gjs index 4d1ea3cf6d6da..b964e09f2c1fd 100644 --- a/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/node/configurator.gjs +++ b/plugins/discourse-workflows/admin/assets/javascripts/admin/components/workflows/node/configurator.gjs @@ -185,6 +185,7 @@ export default class NodeConfigurator extends Component { handleClose() { this.args.model.onSave(this.configuration, this.nodeName); this.args.closeModal(); + this.workflowsNodeTypes.clearEditingContext(); }