From 7bd7569acb9e5757622b3983666cab2be05f3c4f Mon Sep 17 00:00:00 2001 From: Jonathan Adeline Date: Fri, 14 Nov 2025 00:02:28 +0400 Subject: [PATCH 1/2] Advanced formula editor fixings (#4203) --- .../core/assets/scss/components/all.scss | 2 + .../scss/components/formula_input_field.scss | 86 +--- .../function_formula_component.scss | 34 ++ .../components/get_formula_component.scss | 2 +- .../operator_formula_component.scss | 20 + .../formula/ContextManagementExtension.js | 4 +- .../formula/FormulaClipboardHandler.js | 84 ++++ .../components/formula/FormulaInputField.vue | 71 ++-- .../formula/FormulaInsertionExtension.js | 194 ++++++++- .../formula/FunctionAutoCompleteExtension.js | 100 ----- .../formula/FunctionDeletionExtension.js | 210 ---------- .../formula/FunctionDetectionExtension.js | 313 ++++++++++++++ .../formula/FunctionFormulaComponent.vue | 23 ++ .../formula/FunctionHighlightExtension.js | 388 ------------------ .../formula/GetFormulaComponent.vue | 6 +- .../formula/OperatorDetectionExtension.js | 153 +++++++ .../formula/OperatorFormulaComponent.vue | 27 ++ web-frontend/modules/core/formula/index.js | 31 +- .../core/formula/tiptap/fromTipTapVisitor.js | 49 ++- .../core/formula/tiptap/toTipTapVisitor.js | 120 +++++- 20 files changed, 1089 insertions(+), 828 deletions(-) create mode 100644 web-frontend/modules/core/assets/scss/components/function_formula_component.scss create mode 100644 web-frontend/modules/core/assets/scss/components/operator_formula_component.scss create mode 100644 web-frontend/modules/core/components/formula/FormulaClipboardHandler.js delete mode 100644 web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js delete mode 100644 web-frontend/modules/core/components/formula/FunctionDeletionExtension.js create mode 100644 web-frontend/modules/core/components/formula/FunctionDetectionExtension.js create mode 100644 web-frontend/modules/core/components/formula/FunctionFormulaComponent.vue delete mode 100644 web-frontend/modules/core/components/formula/FunctionHighlightExtension.js create mode 100644 web-frontend/modules/core/components/formula/OperatorDetectionExtension.js create mode 100644 web-frontend/modules/core/components/formula/OperatorFormulaComponent.vue diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index 557d0e2aef..1ddecb6145 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -169,6 +169,8 @@ @import 'formula_input_field'; @import 'node_help_tooltip'; @import 'get_formula_component'; +@import 'function_formula_component'; +@import 'operator_formula_component'; @import 'color_input'; @import 'group_bys'; @import 'node_explorer/node_explorer'; diff --git a/web-frontend/modules/core/assets/scss/components/formula_input_field.scss b/web-frontend/modules/core/assets/scss/components/formula_input_field.scss index 49d58cba89..79f4a7e93a 100644 --- a/web-frontend/modules/core/assets/scss/components/formula_input_field.scss +++ b/web-frontend/modules/core/assets/scss/components/formula_input_field.scss @@ -1,60 +1,9 @@ -.function-name-highlight { - color: $palette-cyan-800; - font-weight: 500; - background-color: $palette-cyan-50; - padding: 4px 8px; - height: 24px; - display: inline-block; - vertical-align: top; - - @include rounded; -} - -.operator-highlight { - color: $palette-green-800; - font-weight: 500; - background-color: $palette-green-50; - padding: 3px 8px; - height: 24px; - box-sizing: border-box; - display: inline-block; - vertical-align: top; - - @include rounded; -} - -.text-segment { - min-height: 24px; - display: inline-block; - vertical-align: top; - padding: 3px 0; - margin-right: 4px; - line-height: 18px; -} - -.function-comma-highlight { - margin-right: 4px; -} - -.function-comma-highlight, -.function-paren-highlight { - color: $palette-cyan-800; - font-weight: 500; - background-color: $palette-cyan-50; - padding: 4px 8px; - height: 24px; - box-sizing: border-box; - display: inline-block; - vertical-align: top; - - @include rounded; -} - .formula-input-field { height: auto; font-size: 13px; min-height: 36px; - padding: 5px 12px 1px; + line-height: 25px; + padding: 5px 12px; // If the field is empty, then give it the // same padding as a normal form input field. @@ -68,27 +17,13 @@ padding: 10px 12px; min-height: 48px; } - - // Remove margin from the last element to avoid trailing space - /* stylelint-disable-next-line selector-class-pattern */ - .ProseMirror { - span:last-child { - margin-right: 0; - } - - > div { - > span:not(.text-segment) { - margin: 0 4px 4px 0; - } - } - } } .formula-input-field--focused { border-color: $palette-blue-500; &.formula-input-field--error { - border-color: $palette-red-400; + border-color: $palette-red-400 !important; } } @@ -112,3 +47,18 @@ pointer-events: none; height: 0; } + +// Atomic comma style +.formula-input-field__comma, +.formula-input-field__parenthesis { + color: $palette-cyan-800; + font-weight: 500; + background-color: $palette-cyan-50; + padding: 0 8px; + height: 24px; + box-sizing: border-box; + display: inline-block; + vertical-align: top; + + @include rounded; +} diff --git a/web-frontend/modules/core/assets/scss/components/function_formula_component.scss b/web-frontend/modules/core/assets/scss/components/function_formula_component.scss new file mode 100644 index 0000000000..c739891d6c --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/function_formula_component.scss @@ -0,0 +1,34 @@ +.function-formula-component { + height: 24px; + display: inline-block; + vertical-align: top; +} + +.function-formula-component__name { + color: $palette-cyan-800; + font-weight: 500; + background-color: $palette-cyan-50; + padding: 0 8px; + display: inline-block; + vertical-align: top; + height: 24px; + + @include rounded; + + .function-formula-component:has(+ .function-formula-component) & { + padding-right: 0; + } +} + +.function-formula-component__parenthesis { + color: $palette-cyan-800; + font-weight: 500; + background-color: $palette-cyan-50; + padding: 0 8px; + height: 24px; + box-sizing: border-box; + display: inline-block; + vertical-align: top; + + @include rounded; +} diff --git a/web-frontend/modules/core/assets/scss/components/get_formula_component.scss b/web-frontend/modules/core/assets/scss/components/get_formula_component.scss index b67e72b26c..2688d0fae6 100644 --- a/web-frontend/modules/core/assets/scss/components/get_formula_component.scss +++ b/web-frontend/modules/core/assets/scss/components/get_formula_component.scss @@ -1,7 +1,7 @@ .get-formula-component { cursor: pointer; display: inline-block; - vertical-align: middle; + vertical-align: top; background-color: $palette-neutral-100; font-size: 12px; border-radius: 3px; diff --git a/web-frontend/modules/core/assets/scss/components/operator_formula_component.scss b/web-frontend/modules/core/assets/scss/components/operator_formula_component.scss new file mode 100644 index 0000000000..230be7de7d --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/operator_formula_component.scss @@ -0,0 +1,20 @@ +.operator-formula-component { + display: inline-block; + vertical-align: top; + white-space: normal; + user-select: none; + cursor: default; +} + +.operator-formula-component__symbol { + color: $palette-green-800; + font-weight: 500; + background-color: $palette-green-50; + padding: 0 8px; + height: 24px; + box-sizing: border-box; + display: inline-block; + vertical-align: top; + + @include rounded; +} diff --git a/web-frontend/modules/core/components/formula/ContextManagementExtension.js b/web-frontend/modules/core/components/formula/ContextManagementExtension.js index fa3050bc9c..b0166a5edd 100644 --- a/web-frontend/modules/core/components/formula/ContextManagementExtension.js +++ b/web-frontend/modules/core/components/formula/ContextManagementExtension.js @@ -52,7 +52,7 @@ export const ContextManagementExtension = Extension.create({ switch (contextPosition) { case 'left': config = { - vertical: 'top', + vertical: 'bottom', horizontal: 'left', needsDynamicOffset: true, } @@ -67,7 +67,7 @@ export const ContextManagementExtension = Extension.create({ break case 'right': config = { - vertical: 'top', + vertical: 'bottom', horizontal: 'left', needsDynamicOffset: true, } diff --git a/web-frontend/modules/core/components/formula/FormulaClipboardHandler.js b/web-frontend/modules/core/components/formula/FormulaClipboardHandler.js new file mode 100644 index 0000000000..4c3701c70a --- /dev/null +++ b/web-frontend/modules/core/components/formula/FormulaClipboardHandler.js @@ -0,0 +1,84 @@ +/** + * Creates a clipboard text serializer for the formula editor + * @param {Function} toFormula - Function to convert editor content to formula string + * @returns {Function} Serializer function + */ +export function createClipboardTextSerializer(toFormula) { + return (slice) => { + // Serialize the slice to formula text + const content = { + type: 'doc', + content: [{ type: 'wrapper', content: [] }], + } + + // Extract nodes from the slice + const nodes = [] + slice.content.forEach((node) => { + nodes.push(node.toJSON()) + }) + + content.content[0].content = nodes + + // Convert to formula string + const formula = toFormula(content) + + return formula || '' + } +} + +/** + * Creates a paste handler for the formula editor + * @param {Object} options - Handler options + * @param {Function} options.toContent - Function to parse formula string to editor content + * @param {Function} options.getMode - Function to get current editor mode + * @returns {Function} Paste handler function + */ +export function createPasteHandler({ toContent, getMode }) { + return (view, event, slice) => { + // Only handle paste in advanced mode + if (getMode() !== 'advanced') { + return false + } + + // Get the pasted text + const text = event.clipboardData.getData('text/plain') + if (!text) { + return false + } + + // Try to parse it as a formula + try { + const content = toContent(text) + if (!content) { + return false + } + + // Get the wrapper content (skip doc and wrapper nodes) + const wrapperContent = + content.content && content.content[0] && content.content[0].content + ? content.content[0].content + : [] + + // Insert the parsed content at the current selection + if (wrapperContent.length > 0) { + const { tr } = view.state + const { from, to } = view.state.selection + + // Create nodes from the content + const nodes = wrapperContent.map((item) => + view.state.schema.nodeFromJSON(item) + ) + + // Replace the selection with the nodes + tr.replaceWith(from, to, nodes) + view.dispatch(tr) + return true + } + } catch (error) { + console.error('Error parsing pasted formula:', error) + return false + } + + return false + } +} diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index 1626bf5f33..e818fd431f 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -42,13 +42,23 @@ import { Placeholder } from '@tiptap/extension-placeholder' import { Document } from '@tiptap/extension-document' import { Text } from '@tiptap/extension-text' import { History } from '@tiptap/extension-history' -import { FunctionHighlightExtension } from '@baserow/modules/core/components/formula/FunctionHighlightExtension' -import { FunctionAutoCompleteExtension } from '@baserow/modules/core/components/formula/FunctionAutoCompleteExtension' -import { FunctionDeletionExtension } from '@baserow/modules/core/components/formula/FunctionDeletionExtension' +import { HardBreak } from '@tiptap/extension-hard-break' import { FunctionHelpTooltipExtension } from '@baserow/modules/core/components/formula/FunctionHelpTooltipExtension' -import { FormulaInsertionExtension } from '@baserow/modules/core/components/formula/FormulaInsertionExtension' +import { + FormulaInsertionExtension, + FunctionFormulaComponentNode, + FunctionArgumentCommaNode, + FunctionClosingParenNode, + OperatorFormulaComponentNode, +} from '@baserow/modules/core/components/formula/FormulaInsertionExtension' import { NodeSelectionExtension } from '@baserow/modules/core/components/formula/NodeSelectionExtension' import { ContextManagementExtension } from '@baserow/modules/core/components/formula/ContextManagementExtension' +import { FunctionDetectionExtension } from '@baserow/modules/core/components/formula/FunctionDetectionExtension' +import { OperatorDetectionExtension } from '@baserow/modules/core/components/formula/OperatorDetectionExtension' +import { + createClipboardTextSerializer, + createPasteHandler, +} from '@baserow/modules/core/components/formula/FormulaClipboardHandler' import _ from 'lodash' import parseBaserowFormula from '@baserow/modules/core/formula/parser/parser' import { ToTipTapVisitor } from '@baserow/modules/core/formula/tiptap/toTipTapVisitor' @@ -254,20 +264,31 @@ export default { FunctionHelpTooltipExtension.configure({ vueComponent: this, }), - FunctionHighlightExtension.configure({ - functionNames: this.mode === 'advanced' ? this.functionNames : [], - operators: this.mode === 'advanced' ? this.operators : [], - }), ...this.formulaComponents, ] if (this.mode === 'advanced') { + extensions.push(FunctionFormulaComponentNode) + extensions.push(FunctionArgumentCommaNode) + extensions.push(FunctionClosingParenNode) + extensions.push(OperatorFormulaComponentNode) + extensions.push( + HardBreak.extend({ + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.setHardBreak(), + } + }, + }) + ) extensions.push( - FunctionAutoCompleteExtension.configure({ + FunctionDetectionExtension.configure({ functionNames: this.functionNames, + vueComponent: this, }), - FunctionDeletionExtension.configure({ - functionNames: this.functionNames, + OperatorDetectionExtension.configure({ + operators: this.operators, + vueComponent: this, }) ) } @@ -382,11 +403,18 @@ export default { parseOptions: { preserveWhitespace: 'full', }, - editorProps: {}, + editorProps: { + clipboardTextSerializer: createClipboardTextSerializer( + this.toFormula.bind(this) + ), + handlePaste: createPasteHandler({ + toContent: this.toContent.bind(this), + getMode: () => this.mode, + }), + }, }) }, recreateEditor(formula = null) { - // If no formula is provided, save the current formula before destroying the editor const currentFormula = formula || (this.editor ? this.toFormula(this.wrapperContent) : this.value) @@ -435,23 +463,6 @@ export default { } } - if (this.readOnly) { - return { - type: 'doc', - content: [ - { - type: 'wrapper', - content: [ - { - type: 'text', - text: formula, - }, - ], - }, - ], - } - } - try { const tree = parseBaserowFormula(formula) const functionCollection = new RuntimeFunctionCollection(this.$registry) diff --git a/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js b/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js index 3580889c0b..f938e14b61 100644 --- a/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js +++ b/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js @@ -1,6 +1,8 @@ import { Node, mergeAttributes, Extension } from '@tiptap/core' import { VueNodeViewRenderer } from '@tiptap/vue-2' import GetFormulaComponent from '@baserow/modules/core/components/formula/GetFormulaComponent' +import FunctionFormulaComponent from '@baserow/modules/core/components/formula/FunctionFormulaComponent' +import OperatorFormulaComponent from '@baserow/modules/core/components/formula/OperatorFormulaComponent' export const GetFormulaComponentNode = Node.create({ name: 'get-formula-component', @@ -39,6 +41,141 @@ export const GetFormulaComponentNode = Node.create({ }, }) +export const FunctionFormulaComponentNode = Node.create({ + name: 'function-formula-component', + group: 'inline', + inline: true, + draggable: false, + selectable: false, + + addAttributes() { + return { + functionName: { + default: null, + }, + argumentCount: { + default: 0, + }, + isSelected: { + default: false, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'span[data-formula-component="function-formula-component"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { 'data-formula-component': this.name }), + ] + }, + + addNodeView() { + return VueNodeViewRenderer(FunctionFormulaComponent) + }, +}) + +// Atomic comma node for function arguments +export const FunctionArgumentCommaNode = Node.create({ + name: 'function-argument-comma', + group: 'inline', + inline: true, + draggable: false, + selectable: false, + + parseHTML() { + return [ + { + tag: 'span[data-formula-comma="true"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { + 'data-formula-comma': 'true', + class: 'formula-input-field__comma', + }), + ',', + ] + }, +}) + +// Atomic closing parenthesis node for functions +export const FunctionClosingParenNode = Node.create({ + name: 'function-closing-paren', + group: 'inline', + inline: true, + draggable: false, + selectable: false, + + parseHTML() { + return [ + { + tag: 'span[data-formula-closing-paren="true"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { + 'data-formula-closing-paren': 'true', + class: 'formula-input-field__parenthesis', + }), + ')', + ] + }, +}) + +// Operator formula component node +export const OperatorFormulaComponentNode = Node.create({ + name: 'operator-formula-component', + group: 'inline', + inline: true, + draggable: false, + selectable: false, + + addAttributes() { + return { + operatorSymbol: { + default: null, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'span[data-formula-component="operator-formula-component"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { + 'data-formula-component': this.name, + }), + ] + }, + + addNodeView() { + return VueNodeViewRenderer(OperatorFormulaComponent) + }, +}) + export const FormulaInsertionExtension = Extension.create({ name: 'formulaInsertion', addCommands() { @@ -57,20 +194,49 @@ export const FormulaInsertionExtension = Extension.create({ }, insertFunction: (node) => - ({ editor, commands }) => { + ({ editor, commands, state }) => { const functionName = node.name - // Insert zero-width space so cursor can be positioned in the text-segment - const functionText = functionName + '(\u200B)' + const minArgs = node.signature?.minArgs || 1 - const { state } = editor - const startPos = state.selection.from + // Get initial cursor position + const initialPos = state.selection.from - commands.insertContent(functionText) + // Build all content to insert + const contentToInsert = [] - // Position cursor after the zero-width space (in the text-segment) - const cursorPos = startPos + functionName.length + 2 + // Add function component + contentToInsert.push({ + type: 'function-formula-component', + attrs: { + functionName, + }, + }) + + // Add comma-separated placeholders based on minimum args + if (minArgs >= 2) { + for (let i = 0; i < minArgs - 1; i++) { + contentToInsert.push({ + type: 'function-argument-comma', + }) + } + } + + // Add closing parenthesis + contentToInsert.push({ + type: 'function-closing-paren', + }) - commands.setTextSelection({ from: cursorPos, to: cursorPos }) + // Insert all content at once + commands.insertContent(contentToInsert) + + // Position cursor right after the function component (before commas and closing paren) + // The function component is 1 node, so cursor should be at initialPos + 1 + const targetPos = initialPos + 1 + + commands.setTextSelection({ + from: targetPos, + to: targetPos, + }) commands.focus() @@ -79,7 +245,15 @@ export const FormulaInsertionExtension = Extension.create({ insertOperator: (node) => ({ editor, commands }) => { - commands.insertContent(node.signature.operator) + const operatorSymbol = node.signature.operator + + // Insert operator as an operator-formula-component node + commands.insertContent({ + type: 'operator-formula-component', + attrs: { + operatorSymbol, + }, + }) commands.focus() diff --git a/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js b/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js deleted file mode 100644 index 27621e2e97..0000000000 --- a/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js +++ /dev/null @@ -1,100 +0,0 @@ -import { Extension } from '@tiptap/core' -import { Plugin, PluginKey } from 'prosemirror-state' -import { isInsideStringInText } from './FormulaExtensionHelpers' - -const functionAutoCompletePluginKey = new PluginKey('functionAutoComplete') - -/** - * @name FunctionAutoCompleteExtension - * @description This Tiptap extension enhances the user experience by automatically - * closing parentheses for function calls. When a user types a recognized function - * name followed by an opening parenthesis `(`, this extension inserts the matching - * closing parenthesis `)` and places the cursor in between them, ready for argument - * input. It also adds spacing after commas to position the cursor in a text-segment. - */ -export const FunctionAutoCompleteExtension = Extension.create({ - name: 'functionAutoComplete', - - addOptions() { - return { - functionNames: [], - } - }, - - addProseMirrorPlugins() { - const functionNames = this.options.functionNames - - return [ - new Plugin({ - key: functionAutoCompletePluginKey, - props: { - handleTextInput(view, from, to, text) { - const { state } = view - const { doc } = state - - if (text === '(') { - const textBefore = - doc.textBetween(Math.max(0, from - 20), to) + text - - // Check if we're inside a string literal - if (isInsideStringInText(textBefore)) { - return false - } - - if (functionNames.length === 0) { - return false - } - - const functionPattern = new RegExp( - `\\b(${functionNames.join('|')})\\s*\\($`, - 'i' - ) - const match = textBefore.match(functionPattern) - - if (match) { - const tr = state.tr - - tr.insertText(text, from, to) - - // Insert zero-width space and closing parenthesis - tr.insertText('\u200B)', from + 1) - - // Position cursor after the zero-width space (in the text-segment) - tr.setSelection( - state.selection.constructor.near(tr.doc.resolve(from + 2)) - ) - - view.dispatch(tr) - return true - } - } - - // Handle comma input to add space and position cursor in text-segment - if (text === ',') { - // Check if we're inside a string literal - const textBefore = doc.textBetween(Math.max(0, from - 20), to) - if (isInsideStringInText(textBefore + text)) { - return false - } - - const tr = state.tr - - // Insert comma followed by zero-width space - tr.insertText(',\u200B', from, to) - - // Position cursor after the zero-width space (in the text-segment) - tr.setSelection( - state.selection.constructor.near(tr.doc.resolve(from + 2)) - ) - - view.dispatch(tr) - return true - } - - return false - }, - }, - }), - ] - }, -}) diff --git a/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js b/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js deleted file mode 100644 index afb34974c4..0000000000 --- a/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js +++ /dev/null @@ -1,210 +0,0 @@ -import { Extension } from '@tiptap/core' -import { Plugin, PluginKey } from '@tiptap/pm/state' -import { - findClosedStringRangesInNodeMap, - isInsideClosedStringInNodeMap, - isAfterUnclosedQuoteInNodeMap, -} from './FormulaExtensionHelpers' - -const functionDeletionPluginKey = new PluginKey('functionDeletion') - -/** - * @name FunctionDeletionExtension - * @description A Tiptap extension that provides "smart" deletion for function - * calls. When the user presses `Backspace` on a character that is part of a - * function's syntax (like the parenthesis or a comma), this extension deletes the - * entire function call, including its arguments, instead of just a single character. - * This prevents leaving syntactically invalid remnants of a function. - */ -export const FunctionDeletionExtension = Extension.create({ - name: 'functionDeletion', - - addOptions() { - return { - functionNames: [], - } - }, - - addProseMirrorPlugins() { - const functionNames = this.options.functionNames - - const deleteFunctionRange = (state, view, startPos, endPos) => { - if ( - startPos < endPos && - startPos >= 0 && - endPos <= state.doc.content.size - ) { - const tr = state.tr.delete(startPos, endPos) - view.dispatch(tr) - return true - } - return false - } - - const findFunctionBoundaries = (state, cursorPos, functionNames) => { - const nodeMap = [] - state.doc.descendants((node, pos) => { - nodeMap.push({ - node, - pos, - end: pos + node.nodeSize, - isText: node.isText, - isDataComponent: node.type.name === 'get-formula-component', - text: node.isText ? node.text : '', - }) - }) - - const stringRanges = findClosedStringRangesInNodeMap(nodeMap) - - const candidates = [] - - for (let i = 0; i < nodeMap.length; i++) { - const item = nodeMap[i] - - if (item.isText && item.text) { - const functionMatches = [...item.text.matchAll(/(\w+)\(/g)] - - for (const match of functionMatches) { - const funcName = match[1] - const matchStart = item.pos + match.index - const matchEnd = matchStart + match[0].length - - if (!functionNames.includes(funcName)) continue - - // Skip if this function name is inside a string literal (closed or unclosed) - if ( - isInsideClosedStringInNodeMap( - nodeMap, - matchStart, - stringRanges - ) || - isAfterUnclosedQuoteInNodeMap(nodeMap, matchStart) - ) - continue - - let openParens = 1 - let closingParenPos = -1 - - for (let j = i; j < nodeMap.length && openParens > 0; j++) { - const searchItem = nodeMap[j] - - if (searchItem.isText && searchItem.text) { - let textToSearch = searchItem.text - let textStartPos = searchItem.pos - - if (j === i) { - const skipIndex = match.index + match[0].length - textToSearch = searchItem.text.substring(skipIndex) - textStartPos = searchItem.pos + skipIndex - } - - for (let k = 0; k < textToSearch.length; k++) { - const currentPos = textStartPos + k - const char = textToSearch[k] - - // Only ignore parentheses that are inside CLOSED strings - if ( - !isInsideClosedStringInNodeMap( - nodeMap, - currentPos, - stringRanges - ) - ) { - if (char === '(') { - openParens++ - } else if (char === ')') { - openParens-- - if (openParens === 0) { - closingParenPos = textStartPos + k + 1 - break - } - } - } - } - - if (closingParenPos !== -1) break - } - } - - if (closingParenPos !== -1) { - const isInFunctionRange = - cursorPos >= matchStart && cursorPos <= closingParenPos - - if (isInFunctionRange) { - const shouldDelete = - (cursorPos >= matchStart + funcName.length && - cursorPos <= matchEnd) || - cursorPos === matchEnd || - cursorPos === closingParenPos - - if (shouldDelete) { - candidates.push({ - start: matchStart, - end: closingParenPos, - functionName: funcName, - size: closingParenPos - matchStart, - }) - } - } - } - } - } - } - - if (candidates.length > 0) { - candidates.sort((a, b) => a.size - b.size) - return candidates[0] - } - - return null - } - - const handleFunctionDeletion = (state, view, functionNames) => { - const { from } = state.selection - - const boundaries = findFunctionBoundaries(state, from, functionNames) - - if (boundaries) { - return deleteFunctionRange( - state, - view, - boundaries.start, - boundaries.end - ) - } - - return false - } - - return [ - new Plugin({ - key: functionDeletionPluginKey, - props: { - handleKeyDown: (view, event) => { - if (event.key !== 'Backspace') { - return false - } - - const { state } = view - const { selection } = state - const { from, to } = selection - - if (from !== to) { - return false - } - - const nodeAtCursor = state.doc.nodeAt(from - 1) - if ( - nodeAtCursor && - nodeAtCursor.type.name === 'get-formula-component' - ) { - return false - } - - return handleFunctionDeletion(state, view, functionNames) - }, - }, - }), - ] - }, -}) diff --git a/web-frontend/modules/core/components/formula/FunctionDetectionExtension.js b/web-frontend/modules/core/components/formula/FunctionDetectionExtension.js new file mode 100644 index 0000000000..7bea21c777 --- /dev/null +++ b/web-frontend/modules/core/components/formula/FunctionDetectionExtension.js @@ -0,0 +1,313 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' +import { Fragment } from '@tiptap/pm/model' + +export const FunctionDetectionExtension = Extension.create({ + name: 'functionDetection', + + addOptions() { + return { + functionNames: [], + vueComponent: null, + } + }, + + addProseMirrorPlugins() { + const functionNames = this.options.functionNames + const vueComponent = this.options.vueComponent + + function handleOpeningParenthesis(view, from, to) { + const { state } = view + const { doc } = state + + // Get the text before the cursor, use comma as separator for nodes + const textBefore = doc.textBetween(Math.max(0, from - 50), from, ',') + + // Check if the text before ends with a function name + // Look for function names that are either at the start or preceded by non-letter characters (including comma) + const functionPattern = new RegExp( + `(^|[^a-zA-Z])(${functionNames.join('|')})(\\s*)$`, + 'i' + ) + const match = textBefore.match(functionPattern) + + if (match) { + const functionName = match[2] + const spacesBeforeParenthesis = match[3] || '' + const functionStart = + from - functionName.length - spacesBeforeParenthesis.length + + // Find the function definition + const functionDef = vueComponent.nodesHierarchy + .flatMap((cat) => cat.nodes || []) + .flatMap((n) => n.nodes || []) + .find( + (node) => + node.type === 'function' && + node.name.toLowerCase() === functionName.toLowerCase() + ) + + if (functionDef) { + const signature = functionDef.signature || {} + const minArgs = signature.minArgs || 0 + + // Create a transaction to replace the function text with the component + const tr = state.tr + + // Build all nodes to insert + const nodesToInsert = [] + + // Insert function node (atomic) + const functionNode = state.schema.nodes[ + 'function-formula-component' + ].create({ + functionName, + }) + nodesToInsert.push(functionNode) + + // Add comma-separated placeholders based on minimum args + if (minArgs >= 2) { + for (let i = 0; i < minArgs - 1; i++) { + // Add atomic comma + const commaNode = + state.schema.nodes['function-argument-comma'].create() + nodesToInsert.push(commaNode) + } + } + + // Insert the closing parenthesis as atomic node + const closingParenNode = + state.schema.nodes['function-closing-paren'].create() + nodesToInsert.push(closingParenNode) + + // Insert all nodes at once using Fragment.from + const fragment = Fragment.from(nodesToInsert) + + // Replace the function name + opening parenthesis with our nodes + tr.replaceWith(functionStart, to, fragment) + + // Position cursor right after the function component (opening parenthesis) + const cursorPos = functionStart + 1 + + tr.setSelection(TextSelection.create(tr.doc, cursorPos)) + + // Apply the transaction + view.dispatch(tr) + return true + } + } + + return false + } + + function handleComma(view, from, to) { + const { state } = view + const { doc } = state + + // Check if we're inside a function + if (!isInsideFunction(doc, from)) { + return false + } + + // Check if we're inside a string literal + if (isInsideStringLiteral(doc, from)) { + return false + } + + // Create transaction + const tr = state.tr + + // Create the atomic comma node + const commaNode = state.schema.nodes['function-argument-comma'].create() + + // Replace the typed comma with the atomic node + tr.replaceWith(from, to, commaNode) + + // Position cursor after the comma + const cursorPos = from + 1 + tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos))) + + view.dispatch(tr) + return true + } + + function isInsideFunction(doc, pos) { + // Get the resolved position + const $pos = doc.resolve(pos) + + // Find the parent wrapper node + let $wrapper = null + for (let d = $pos.depth; d > 0; d--) { + if ($pos.node(d).type.name === 'wrapper') { + $wrapper = $pos.start(d) + break + } + } + + if ($wrapper === null) return false + + // Look for a function component before the cursor position + let foundFunction = false + let parenCount = 0 + + doc.nodesBetween($wrapper, pos, (node, nodePos) => { + if (nodePos >= pos) return false // Stop when we reach cursor position + + if (node.type.name === 'function-formula-component') { + foundFunction = true + parenCount = 1 // Function component includes opening paren + } else if (node.type.name === 'text' && foundFunction) { + // Count parentheses in text nodes + const text = node.text + for (let i = 0; i < text.length; i++) { + // Only count characters before cursor position + if (nodePos + i >= pos) break + + if (text[i] === '(') { + parenCount++ + } else if (text[i] === ')') { + parenCount-- + if (parenCount === 0) { + foundFunction = false // We've closed the function + } + } + } + } + }) + + return foundFunction && parenCount > 0 + } + + function isInsideStringLiteral(doc, pos) { + // Get text from start of current context to cursor position + const contextStart = Math.max(0, pos - 200) // Look back up to 200 chars + const textBefore = doc.textBetween(contextStart, pos, ' ') + + // Count quotes to determine if we're inside a string + let singleQuoteCount = 0 + let doubleQuoteCount = 0 + let escaped = false + + for (let i = 0; i < textBefore.length; i++) { + const char = textBefore[i] + + if (escaped) { + escaped = false + continue + } + + if (char === '\\') { + escaped = true + } else if (char === "'") { + singleQuoteCount++ + } else if (char === '"') { + doubleQuoteCount++ + } + } + + // If odd number of quotes, we're inside a string + return singleQuoteCount % 2 === 1 || doubleQuoteCount % 2 === 1 + } + + function handleClosingParenthesis(view, from, to) { + const { state } = view + const { doc } = state + + // Check if we're closing a function + if (!isClosingFunction(doc, from)) { + return false + } + + // Check if we're inside a string literal + if (isInsideStringLiteral(doc, from)) { + return false + } + + // Replace the closing parenthesis with an atomic node + const tr = state.tr + const closingParenNode = + state.schema.nodes['function-closing-paren'].create() + + // Replace the typed closing paren with the atomic node + tr.replaceWith(from, to, closingParenNode) + + // Position cursor after the closing paren + const cursorPos = from + 1 + tr.setSelection( + state.selection.constructor.near(tr.doc.resolve(cursorPos)) + ) + + view.dispatch(tr) + return true + } + + function isClosingFunction(doc, pos) { + // Get the resolved position + const $pos = doc.resolve(pos) + + // Find the parent wrapper node + let $wrapper = null + for (let d = $pos.depth; d > 0; d--) { + if ($pos.node(d).type.name === 'wrapper') { + $wrapper = $pos.start(d) + break + } + } + + if ($wrapper === null) return false + + // Count parentheses to see if we're closing a function + let parenCount = 0 + let foundFunction = false + + doc.nodesBetween($wrapper, pos, (node, nodePos) => { + if (nodePos >= pos) return false + + if (node.type.name === 'function-formula-component') { + foundFunction = true + parenCount = 1 // Opening paren is part of the component + } else if (node.type.name === 'text') { + const text = node.text + for (let i = 0; i < text.length; i++) { + if (nodePos + i >= pos) break + + if (text[i] === '(') { + parenCount++ + } else if (text[i] === ')') { + parenCount-- + } + } + } + }) + + // We're closing a function if we found one and have exactly 1 open paren + return foundFunction && parenCount === 1 + } + + return [ + new Plugin({ + key: new PluginKey('functionDetection'), + props: { + handleTextInput(view, from, to, text) { + // Process opening parenthesis for function detection + if (text === '(') { + return handleOpeningParenthesis(view, from, to) + } + + // Process comma for argument separation + if (text === ',') { + return handleComma(view, from, to) + } + + // Process closing parenthesis + if (text === ')') { + return handleClosingParenthesis(view, from, to) + } + + return false + }, + }, + }), + ] + }, +}) diff --git a/web-frontend/modules/core/components/formula/FunctionFormulaComponent.vue b/web-frontend/modules/core/components/formula/FunctionFormulaComponent.vue new file mode 100644 index 0000000000..9e7f9d99d2 --- /dev/null +++ b/web-frontend/modules/core/components/formula/FunctionFormulaComponent.vue @@ -0,0 +1,23 @@ + + + diff --git a/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js b/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js deleted file mode 100644 index 4c244e29db..0000000000 --- a/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js +++ /dev/null @@ -1,388 +0,0 @@ -import { Extension } from '@tiptap/core' -import { Plugin, PluginKey } from 'prosemirror-state' -import { Decoration, DecorationSet } from 'prosemirror-view' -import { - findClosedStringRanges, - isInsideClosedString, - isAfterUnclosedQuote, - matchesAt, - findClosingParen, -} from './FormulaExtensionHelpers' - -const functionHighlightPluginKey = new PluginKey('functionHighlight') - -// ============================================================================ -// Function Detection -// ============================================================================ - -/** - * Finds all complete function ranges in the document - */ -const findFunctionRanges = (documentContent, functionNames, stringRanges) => { - const functionRanges = [] - - for (let i = 0; i < documentContent.length; i++) { - const content = documentContent[i] - if (content.type !== 'text') continue - - // Skip if we're inside a string literal (closed or unclosed) - if ( - isInsideClosedString(documentContent, i, stringRanges) || - isAfterUnclosedQuote(documentContent, i) - ) { - continue - } - - for (const functionName of functionNames) { - if (matchesAt(documentContent, i, functionName, true)) { - const functionStart = i - let j = i + functionName.length - - // Skip whitespace after function name - while ( - j < documentContent.length && - documentContent[j].type === 'text' && - /\s/.test(documentContent[j].char) - ) { - j++ - } - - // Check for opening parenthesis - if ( - j < documentContent.length && - documentContent[j].type === 'text' && - documentContent[j].char === '(' - ) { - const openParenPos = j - const closeParen = findClosingParen( - documentContent, - j + 1, - stringRanges - ) - - // Only add function range if it's complete - if (closeParen !== -1) { - functionRanges.push({ - name: functionName, - start: functionStart, - openParen: openParenPos, - closeParen, - end: closeParen + 1, - }) - } - } - } - } - } - - return functionRanges -} - -// ============================================================================ -// Segment Building -// ============================================================================ - -/** - * Finds the content index for a document position - */ -const findContentIndex = (documentContent, docPos) => { - return documentContent.findIndex( - (c) => c.docPos === docPos && c.type === 'text' - ) -} - -/** - * Builds highlighting segments for a text node - */ -const buildSegments = ( - text, - pos, - documentContent, - functionRanges, - operators, - stringRanges -) => { - const segments = [] - - // Build function name segments - for (const funcRange of functionRanges) { - let funcStartInText = -1 - let funcEndInText = -1 - - for (let i = 0; i < text.length; i++) { - const contentIndex = findContentIndex(documentContent, pos + i) - if (contentIndex === -1) continue - - if ( - contentIndex >= funcRange.start && - contentIndex <= funcRange.openParen - ) { - if (funcStartInText === -1) funcStartInText = i - funcEndInText = i + 1 - } - } - - if (funcStartInText !== -1 && funcEndInText !== -1) { - segments.push({ - start: funcStartInText, - end: funcEndInText, - type: 'function', - functionId: funcRange.start, - }) - } - } - - // Build segments for closing parentheses and commas - for (let i = 0; i < text.length; i++) { - const char = text[i] - const contentIndex = findContentIndex(documentContent, pos + i) - - for (const funcRange of functionRanges) { - if (contentIndex === -1) continue - - // Highlight closing paren - if (contentIndex === funcRange.closeParen) { - segments.push({ - start: i, - end: i + 1, - type: 'function-paren', - }) - } else if ( - char === ',' && - contentIndex > funcRange.openParen && - contentIndex < funcRange.closeParen && - !isInsideClosedString(documentContent, contentIndex, stringRanges) && - !isAfterUnclosedQuote(documentContent, contentIndex) - ) { - segments.push({ - start: i, - end: i + 1, - type: 'function-comma', - }) - } - } - } - - // Build operator segments - if (operators.length > 0) { - const operatorValues = operators - .map((op) => (typeof op === 'string' ? op : op?.operator)) - .filter((op) => op && typeof op === 'string' && op.trim()) - - if (operatorValues.length > 0) { - const escapedOperators = operatorValues - .map((op) => op.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .sort((a, b) => b.length - a.length) - - const operatorPattern = new RegExp(`(${escapedOperators.join('|')})`, 'g') - - let operatorMatch - while ((operatorMatch = operatorPattern.exec(text)) !== null) { - const contentIndex = findContentIndex( - documentContent, - pos + operatorMatch.index - ) - - if ( - contentIndex !== -1 && - !isInsideClosedString(documentContent, contentIndex, stringRanges) && - !isAfterUnclosedQuote(documentContent, contentIndex) - ) { - addToSegments( - segments, - operatorMatch.index, - operatorMatch.index + operatorMatch[0].length, - 'operator' - ) - } - } - } - } - - return segments -} - -/** - * Merges overlapping segments - */ -const addToSegments = (segments, start, end, type, metadata = {}) => { - // Don't merge function segments - each function should have its own span - if (type === 'function') { - segments.push({ start, end, type, ...metadata }) - return - } - - const existing = segments.find( - (s) => - s.type === type && - ((s.start <= start && s.end >= start) || - (s.start <= end && s.end >= end) || - (start <= s.start && end >= s.start)) - ) - - if (existing) { - existing.start = Math.min(existing.start, start) - existing.end = Math.max(existing.end, end) - } else { - segments.push({ start, end, type, ...metadata }) - } -} - -/** - * Gets the CSS class for a segment type - */ -const getSegmentClassName = (segmentType) => { - switch (segmentType) { - case 'function': - return 'function-name-highlight' - case 'function-paren': - return 'function-paren-highlight' - case 'function-comma': - return 'function-comma-highlight' - case 'operator': - return 'operator-highlight' - default: - return 'text-segment' - } -} - -/** - * Applies decorations for segments - */ -const applySegmentDecorations = (segments, text, pos, decorations) => { - let lastIndex = 0 - - segments.forEach((segment) => { - // Add decoration for text before this segment - if (lastIndex < segment.start) { - const beforeText = text.slice(lastIndex, segment.start) - // Create text-segment even for whitespace-only text to provide visual cursor placement - if (beforeText) { - decorations.push( - Decoration.inline(pos + lastIndex, pos + segment.start, { - class: 'text-segment', - }) - ) - } - } - - // Add decoration for the segment itself - decorations.push( - Decoration.inline(pos + segment.start, pos + segment.end, { - class: getSegmentClassName(segment.type), - }) - ) - - lastIndex = segment.end - }) - - // Add decoration for remaining text - if (lastIndex < text.length) { - const remainingText = text.slice(lastIndex) - // Create text-segment even for whitespace-only text to provide visual cursor placement - if (remainingText) { - decorations.push( - Decoration.inline(pos + lastIndex, pos + text.length, { - class: 'text-segment', - }) - ) - } - } - - // If no segments, decorate entire text - if (segments.length === 0 && text) { - decorations.push( - Decoration.inline(pos, pos + text.length, { - class: 'text-segment', - }) - ) - } -} - -// ============================================================================ -// Main Extension -// ============================================================================ -/** - * @name FunctionHighlightExtension - * @description Provides syntax highlighting for the formula editor. This Tiptap - * extension scans the editor's content and applies custom CSS classes to function - * names and operators. It uses ProseMirror's `DecorationSet` to apply inline - * decorations without modifying the actual document content, ensuring that the - * highlighting is purely a visual enhancement. - */ -export const FunctionHighlightExtension = Extension.create({ - name: 'functionHighlight', - - addOptions() { - return { - functionNames: [], - operators: [], - } - }, - - addProseMirrorPlugins() { - const functionNames = this.options.functionNames - const operators = this.options.operators - - return [ - new Plugin({ - key: functionHighlightPluginKey, - props: { - decorations(state) { - const decorations = [] - const doc = state.doc - - const documentContent = [] - doc.descendants((node, pos) => { - if (node.isText && node.text) { - for (let i = 0; i < node.text.length; i++) { - documentContent.push({ - char: node.text[i], - docPos: pos + i, - nodePos: pos, - charIndex: i, - type: 'text', - }) - } - } else if (node.isLeaf && node.type.name !== 'wrapper') { - documentContent.push({ - char: '', - docPos: pos, - nodePos: pos, - charIndex: 0, - type: 'component', - componentType: node.type.name, - }) - } - }) - - const stringRanges = findClosedStringRanges(documentContent) - const functionRanges = findFunctionRanges( - documentContent, - functionNames, - stringRanges - ) - - doc.descendants((node, pos) => { - if (node.isText && node.text) { - const segments = buildSegments( - node.text, - pos, - documentContent, - functionRanges, - operators, - stringRanges - ) - - segments.sort((a, b) => a.start - b.start) - applySegmentDecorations(segments, node.text, pos, decorations) - } - }) - - return DecorationSet.create(doc, decorations) - }, - }, - }), - ] - }, -}) diff --git a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue index 14de66f76d..d09952fa0a 100644 --- a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue +++ b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue @@ -111,7 +111,11 @@ export default { lastNode.type === 'array' && (/^\d+$/.test(identifier) || identifier === '*') ) { - translatedParts.push(identifier) + translatedParts.push( + identifier === '*' + ? `[${this.$t('getFormulaComponent.all')}]` + : identifier + ) } else { translatedParts.push(identifier) currentLevelNodes = null diff --git a/web-frontend/modules/core/components/formula/OperatorDetectionExtension.js b/web-frontend/modules/core/components/formula/OperatorDetectionExtension.js new file mode 100644 index 0000000000..1658672329 --- /dev/null +++ b/web-frontend/modules/core/components/formula/OperatorDetectionExtension.js @@ -0,0 +1,153 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' +import { Fragment } from '@tiptap/pm/model' + +export const OperatorDetectionExtension = Extension.create({ + name: 'operatorDetection', + + addOptions() { + return { + operators: [], + vueComponent: null, + } + }, + + addProseMirrorPlugins() { + const operators = this.options.operators + + function isInsideStringLiteral(doc, pos) { + const text = doc.textBetween(0, pos, '\n') + let inString = false + let quoteChar = null + + for (let i = 0; i < text.length; i++) { + const char = text[i] + if ((char === '"' || char === "'") && text[i - 1] !== '\\') { + if (!inString) { + inString = true + quoteChar = char + } else if (char === quoteChar) { + inString = false + quoteChar = null + } + } + } + + return inString + } + + function shouldConvertOperator(view, from, to, typedChar) { + const { state } = view + const { doc } = state + + // Don't convert if inside a string literal + if (isInsideStringLiteral(doc, from)) { + return false + } + + return true + } + + function handleOperatorTyped(view, from, to, typedText) { + const { state } = view + const { tr, schema } = state + + // Check for compound operators by looking at the previous character + const textBefore = state.doc.textBetween(Math.max(0, from - 2), from, '') + let operatorText = typedText + let startPos = from // Default: insert at current position (don't replace anything before) + let endPos = from // Default: don't remove anything + + // Handle compound operators (!=, >=, <=) + if (typedText === '=') { + // First, check if there's a text character before + const prevChar = textBefore.charAt(textBefore.length - 1) + if (prevChar === '!' || prevChar === '>' || prevChar === '<') { + operatorText = prevChar + '=' + startPos = from - 2 // Start before the !, > or < + endPos = from // End at current position (don't include the = we're typing) + } else { + // Check if there's an operator node right before the cursor + const $pos = state.doc.resolve(from) + const nodeBefore = $pos.nodeBefore + + if ( + nodeBefore && + nodeBefore.type.name === 'operator-formula-component' + ) { + const prevOperator = nodeBefore.attrs.operatorSymbol + + if ( + (prevOperator === '>' || prevOperator === '<') && + operators.includes(prevOperator + '=') + ) { + // Replace the previous operator node with the compound operator + operatorText = prevOperator + '=' + startPos = from - nodeBefore.nodeSize + endPos = from // Replace up to current position + } + } + } + } + + // Check if the operator is in the allowed list + if (!operators.includes(operatorText)) { + return false + } + + // Create operator node + const operatorNode = schema.nodes['operator-formula-component'].create({ + operatorSymbol: operatorText, + }) + + // Create the fragment with just the operator node (no spaces) + const fragment = Fragment.from([operatorNode]) + + // Replace from startPos to endPos with the fragment + tr.replaceWith(startPos, endPos, fragment) + + // Position cursor after the operator + const cursorPos = startPos + fragment.size + tr.setSelection(TextSelection.create(tr.doc, cursorPos)) + + view.dispatch(tr) + return true + } + + // Extract all unique characters from the operators list + const operatorChars = new Set() + operators.forEach((op) => { + for (let i = 0; i < op.length; i++) { + operatorChars.add(op.charAt(i)) + } + }) + + return [ + new Plugin({ + key: new PluginKey('operatorDetection'), + props: { + handleTextInput(view, from, to, text) { + // Only handle operator characters + if (!operatorChars.has(text)) { + return false + } + + // Don't convert if we shouldn't + if (!shouldConvertOperator(view, from, to, text)) { + return false + } + + // For '!', we need to wait for the next character to see if it's '!=' + // We don't wait for '>' and '<' because they are valid operators on their own + if (text === '!') { + return false + } + + // Handle the operator + return handleOperatorTyped(view, to, to, text) + }, + }, + }), + ] + }, +}) diff --git a/web-frontend/modules/core/components/formula/OperatorFormulaComponent.vue b/web-frontend/modules/core/components/formula/OperatorFormulaComponent.vue new file mode 100644 index 0000000000..95f02c4ab0 --- /dev/null +++ b/web-frontend/modules/core/components/formula/OperatorFormulaComponent.vue @@ -0,0 +1,27 @@ + + + diff --git a/web-frontend/modules/core/formula/index.js b/web-frontend/modules/core/formula/index.js index 070eb96b4d..b926b64e77 100644 --- a/web-frontend/modules/core/formula/index.js +++ b/web-frontend/modules/core/formula/index.js @@ -219,11 +219,32 @@ export const buildFormulaFunctionNodes = (app, i18n = null) => { // Get function signature information let signature = null - // Check if function is variadic by looking at its validateNumberOfArgs implementation - const isVariadic = + // Check if function is variadic + // A function is variadic if: + // 1. It has a custom validateNumberOfArgs implementation + // 2. OR it doesn't have args property (like concat) + const hasCustomValidation = instance.validateNumberOfArgs && instance.validateNumberOfArgs !== instance.constructor.prototype.validateNumberOfArgs + const isVariadic = !instance.args || hasCustomValidation + + // Calculate minArgs by testing validateNumberOfArgs + let minArgs = 1 + if (instance.validateNumberOfArgs) { + // Test with increasing numbers of arguments to find the minimum + for (let testCount = 0; testCount <= 10; testCount++) { + const testArgs = new Array(testCount).fill({}) + const isValid = instance.validateNumberOfArgs(testArgs) + if (isValid) { + // This number of args is valid, this is the minimum + minArgs = testCount + break + } + } + } else if (!isVariadic && instance.args) { + minArgs = instance.numArgs ?? instance.args.length + } if (instance.args && instance.args.length > 0) { signature = { @@ -254,7 +275,7 @@ export const buildFormulaFunctionNodes = (app, i18n = null) => { } }), variadic: isVariadic, - minArgs: isVariadic ? 1 : instance.numArgs ?? instance.args.length, + minArgs, maxArgs: isVariadic ? null : instance.numArgs ?? instance.args.length, @@ -268,8 +289,8 @@ export const buildFormulaFunctionNodes = (app, i18n = null) => { }, ], variadic: isVariadic, - minArgs: 1, - maxArgs: null, + minArgs, + maxArgs: isVariadic ? null : 1, } } diff --git a/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js index 27e0e6894b..5f69cbfdee 100644 --- a/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js +++ b/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js @@ -12,6 +12,16 @@ export class FromTipTapVisitor { return this.visitDoc(node) case 'wrapper': return this.visitWrapper(node) + case 'function-formula-component': + return this.visitFunctionFormulaComponent(node) + case 'function-argument-comma': + return ',' + case 'function-closing-paren': + return ')' + case 'operator-formula-component': + return this.visitOperatorFormulaComponent(node) + case 'hardBreak': + return this.visitHardBreak(node) default: return this.visitFunction(node) } @@ -85,7 +95,7 @@ export class FromTipTapVisitor { if (this.mode === 'simple') { return `concat(${node.content.map(this.visit.bind(this)).join(', ')})` } else { - return node.content.map(this.visit.bind(this)).join('\n') + return node.content.map(this.visit.bind(this)).join('') } } @@ -119,12 +129,7 @@ export class FromTipTapVisitor { let fullContent = '' for (let i = 0; i < content.length; i++) { const node = content[i] - if (node.type === 'text') { - // Remove zero-width spaces used for cursor positioning - fullContent += node.text.replace(/\u200B/g, '') - } else { - fullContent += this.visit(node) - } + fullContent += this.visit(node) } const argsStartIndex = fullContent.indexOf('(') @@ -141,8 +146,15 @@ export class FromTipTapVisitor { visitText(node) { // Remove zero-width spaces used for cursor positioning - const cleanText = node.text.replace(/\u200B/g, '') - if (this.mode === 'simple') return `'${cleanText.replace(/'/g, "\\'")}'` + let cleanText = node.text.replace(/\u200B/g, '') + + if (this.mode === 'simple') { + return `'${cleanText.replace(/'/g, "\\'")}'` + } + + // In advanced mode, we need to escape actual newlines in the text + // to make them valid in string literals + cleanText = cleanText.replace(/\n/g, '\\n') return cleanText } @@ -153,4 +165,23 @@ export class FromTipTapVisitor { return formulaFunction?.fromNodeToFormula(node) } + + visitFunctionFormulaComponent(node) { + const functionName = node.attrs?.functionName || '' + // Since the function component now only contains name + opening parenthesis, + // we just return the function name and opening parenthesis. + // The arguments and closing parenthesis are handled separately as text nodes + return `${functionName}(` + } + + visitOperatorFormulaComponent(node) { + const operatorSymbol = node.attrs?.operatorSymbol || '' + return operatorSymbol + } + + visitHardBreak(node) { + // In advanced mode, convert hard breaks to actual newline characters + // that will be part of the string literal + return '\n' + } } diff --git a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js index 6e851e7720..189a613882 100644 --- a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js +++ b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js @@ -51,11 +51,47 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor { // Specific element that helps to recognize root concat return { type: 'newLine' } default: { + if (this.mode === 'advanced') { + // In advanced mode, keep quotes for display + const fullText = ctx.getText() + + // Check if the string contains escaped newlines (\n) + // If so, split it into text nodes and hardBreak nodes + if (fullText.includes('\\n')) { + const quote = fullText[0] // Get the opening quote + const content = fullText.slice(1, -1) // Remove quotes + const parts = content.split('\\n') + + // Create an array of text and hardBreak nodes + const nodes = [] + parts.forEach((part, index) => { + if (index === 0) { + // First part: add opening quote + nodes.push({ type: 'text', text: quote + part }) + } else if (index === parts.length - 1) { + // Last part: add closing quote + nodes.push({ type: 'text', text: part + quote }) + } else { + // Middle parts: no quotes + nodes.push({ type: 'text', text: part }) + } + + // Add hardBreak between parts (but not after the last one) + if (index < parts.length - 1) { + nodes.push({ type: 'hardBreak' }) + } + }) + + return nodes + } + + return { type: 'text', text: fullText } + } + // In simple mode, remove quotes (they will be added back by fromTipTapVisitor) const processedString = this.processString(ctx) if (processedString) { return { type: 'text', text: processedString } } else { - // An empty string is an empty wrapper return { type: 'wrapper' } } } @@ -101,8 +137,29 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor { expr.accept(this) ) + // Special handling for 'get' function in advanced mode + // Remove quotes from the path argument since get expects raw path + const processedArgs = + functionName === 'get' && this.mode === 'advanced' + ? args.map((arg) => { + if (arg.type === 'text' && arg.text) { + let text = arg.text + // Remove quotes if present + if ( + text.length >= 2 && + ((text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'"))) + ) { + text = text.slice(1, -1) + } + return { ...arg, text } + } + return arg + }) + : args + const formulaFunctionType = this.functions.get(functionName) - const node = formulaFunctionType.toNode(args, this.mode) + const node = formulaFunctionType.toNode(processedArgs, this.mode) // If the function returns an array (like concat with newlines in simple mode), // return it directly @@ -139,10 +196,31 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor { content.push(leftArg) } - // Add operator symbol + // Add space before operator + content.push({ + type: 'text', + text: ' ', + }) + + // Add operator symbol as a component in advanced mode, as text in simple mode + if (this.mode === 'advanced') { + content.push({ + type: 'operator-formula-component', + attrs: { + operatorSymbol: formulaFunctionType.getOperatorSymbol, + }, + }) + } else { + content.push({ + type: 'text', + text: formulaFunctionType.getOperatorSymbol, + }) + } + + // Add space after operator content.push({ type: 'text', - text: ` ${formulaFunctionType.getOperatorSymbol} `, + text: ' ', }) // Add right argument @@ -162,6 +240,40 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor { } else if (rightArg) { content.push(rightArg) } + } else if (this.mode === 'advanced') { + // For functions in advanced mode, use the function component + // Create function component node (just name + opening parenthesis) + const functionNode = { + type: 'function-formula-component', + attrs: { + functionName, + }, + } + + // Build the content array with function node + arguments + closing parenthesis + const result = [functionNode] + + // Add arguments as plain text nodes + args.forEach((arg, index) => { + if (index > 0) { + // Add atomic comma node + result.push({ type: 'function-argument-comma' }) + } + + // Add the argument directly + if (Array.isArray(arg)) { + // If arg is an array (from nested function calls in advanced mode), + // spread its elements + result.push(...arg) + } else if (arg) { + result.push(arg) + } + }) + + // Add closing parenthesis as atomic node + result.push({ type: 'function-closing-paren' }) + + return result } else { // For functions, display as: functionName(arg1, arg2, ...) content.push({ type: 'text', text: `${functionName}(` }) From a57a666e43abb3b476e05ab2c662e752509a3d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:03:28 +0100 Subject: [PATCH 2/2] Better fix for workflow action reactivity (#4242) --- .../components/event/WorkflowAction.vue | 2 +- .../builder/store/builderWorkflowAction.js | 28 +++++++++---------- .../components/formula/FormulaInputField.vue | 4 +-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/web-frontend/modules/builder/components/event/WorkflowAction.vue b/web-frontend/modules/builder/components/event/WorkflowAction.vue index 0191e39c92..c1903cd0a4 100644 --- a/web-frontend/modules/builder/components/event/WorkflowAction.vue +++ b/web-frontend/modules/builder/components/event/WorkflowAction.vue @@ -105,7 +105,7 @@ export default { await this.actionUpdateWorkflowAction({ page: this.elementPage, workflowAction: this.workflowAction, - values, + values: differences, }) } catch (error) { this.$refs.actionForm?.reset() diff --git a/web-frontend/modules/builder/store/builderWorkflowAction.js b/web-frontend/modules/builder/store/builderWorkflowAction.js index ab21d6ee37..737edc6e38 100644 --- a/web-frontend/modules/builder/store/builderWorkflowAction.js +++ b/web-frontend/modules/builder/store/builderWorkflowAction.js @@ -60,21 +60,19 @@ const mutations = { order, } = page.workflowActions[index] - const newValue = overwrite - ? populateWorkflowAction({ - id, - page_id: pageId, - element_id: elementId, - event, - order, - ...values, - }) - : { - ...page.workflowActions[index], - ...values, - } - - page.workflowActions.splice(index, 1, newValue) + if (overwrite) { + const newValue = populateWorkflowAction({ + id, + page_id: pageId, + element_id: elementId, + event, + order, + ...values, + }) + page.workflowActions.splice(index, 1, newValue) + } else { + Object.assign(page.workflowActions[index], values) + } }, SET_ITEM(state, { page, workflowAction: workflowActionToSet, values }) { page.workflowActions = page.workflowActions.map((workflowAction) => diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index e818fd431f..2c0b13a0df 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -315,9 +315,7 @@ export default { nodesHierarchy(newValue, oldValue) { // fixes reactivity issue with components in tiptap by forcing the input to // render. - if (!_.isEqual(newValue, oldValue)) { - this.key += 1 - } + this.key += 1 }, disabled(newValue) { this.editor.setOptions({ editable: !newValue && !this.readOnly })