diff --git a/changelog/entries/unreleased/refactor/improve_formula_editor.json b/changelog/entries/unreleased/refactor/improve_formula_editor.json
new file mode 100644
index 0000000000..9369b4d80c
--- /dev/null
+++ b/changelog/entries/unreleased/refactor/improve_formula_editor.json
@@ -0,0 +1,9 @@
+{
+ "type": "refactor",
+ "message": "Improve formula editor",
+ "issue_origin": "github",
+ "issue_number": null,
+ "domain": "core",
+ "bullet_points": [],
+ "created_at": "2025-11-25"
+}
\ No newline at end of file
diff --git a/tests/cases/tip_tap_visitor_cases.json b/tests/cases/tip_tap_visitor_cases.json
index dee731ec2a..9cc4d42c9a 100644
--- a/tests/cases/tip_tap_visitor_cases.json
+++ b/tests/cases/tip_tap_visitor_cases.json
@@ -14,7 +14,12 @@
"formula": "'hello'",
"content": {
"type": "doc",
- "content": [{ "text": "hello", "type": "text" }]
+ "content": [
+ {
+ "type": "wrapper",
+ "content": [{ "text": "hello", "type": "text" }]
+ }
+ ]
}
},
{
@@ -53,9 +58,17 @@
"content": {
"type": "doc",
"content": [
+ {
+ "type": "text",
+ "text": "\u200B"
+ },
{
"type": "get-formula-component",
"attrs": { "path": "data_source.hello.there", "isSelected": false }
+ },
+ {
+ "type": "text",
+ "text": "\u200B"
}
]
}
@@ -68,6 +81,10 @@
{
"type": "wrapper",
"content": [
+ {
+ "type": "text",
+ "text": "\u200B"
+ },
{
"type": "get-formula-component",
"attrs": {
@@ -75,6 +92,10 @@
"isSelected": false
}
},
+ {
+ "type": "text",
+ "text": "\u200B"
+ },
{ "type": "text", "text": "friend :)" }
]
}
@@ -125,12 +146,20 @@
{
"type": "wrapper",
"content": [
+ {
+ "type": "text",
+ "text": "\u200B"
+ },
{
"type": "get-formula-component",
"attrs": {
"path": "data_source.hello.there",
"isSelected": false
}
+ },
+ {
+ "type": "text",
+ "text": "\u200B"
}
]
}
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 79f4a7e93a..1b3c5a2a9c 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
@@ -62,3 +62,82 @@
@include rounded;
}
+
+// Group parenthesis style
+.formula-input-field__group-parenthesis {
+ color: $palette-purple-800;
+ font-weight: 500;
+ background-color: $palette-purple-50;
+ padding: 0 8px;
+ height: 24px;
+ box-sizing: border-box;
+ display: inline-block;
+ vertical-align: top;
+
+ @include rounded;
+}
+
+// Remove left padding for closing parenthesis of functions with no arguments
+.formula-input-field__parenthesis[data-no-args='true'] {
+ padding-left: 0;
+}
+
+// Add margin-right when a function component is followed by another function component
+.function-formula-component:has(+ .function-formula-component) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a function component is followed by a group parenthesis
+.function-formula-component:has(+ .formula-input-field__group-parenthesis) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a parenthesis is followed by another parenthesis
+.formula-input-field__parenthesis:has(+ .formula-input-field__parenthesis) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a parenthesis is followed by a comma
+.formula-input-field__parenthesis:has(+ .formula-input-field__comma) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a comma is followed by a function component
+.formula-input-field__comma:has(+ .function-formula-component) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a comma is followed by a parenthesis
+.formula-input-field__comma:has(+ .formula-input-field__parenthesis) {
+ margin-right: 4px;
+}
+
+// Add margin-left when a comma is preceded by a function component
+.function-formula-component + .formula-input-field__comma {
+ margin-left: 4px;
+}
+
+// Add margin-right when a comma is followed by another comma
+.formula-input-field__comma:has(+ .formula-input-field__comma) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a group parenthesis is followed by another group parenthesis
+.formula-input-field__group-parenthesis:has(
+ + .formula-input-field__group-parenthesis
+ ) {
+ margin-right: 4px;
+}
+
+// Add margin-right when a group parenthesis is followed by a function/operator/comma
+.formula-input-field__group-parenthesis:has(+ .function-formula-component),
+.formula-input-field__group-parenthesis:has(+ .formula-input-field__comma) {
+ margin-right: 4px;
+}
+
+// Remove right padding for function components with no arguments
+.function-formula-component[data-no-args='true'] {
+ .function-formula-component__name {
+ padding-right: 2px;
+ }
+}
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
index c739891d6c..ac1161b23d 100644
--- a/web-frontend/modules/core/assets/scss/components/function_formula_component.scss
+++ b/web-frontend/modules/core/assets/scss/components/function_formula_component.scss
@@ -14,10 +14,6 @@
height: 24px;
@include rounded;
-
- .function-formula-component:has(+ .function-formula-component) & {
- padding-right: 0;
- }
}
.function-formula-component__parenthesis {
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
index 230be7de7d..74c2df7c54 100644
--- a/web-frontend/modules/core/assets/scss/components/operator_formula_component.scss
+++ b/web-frontend/modules/core/assets/scss/components/operator_formula_component.scss
@@ -4,6 +4,7 @@
white-space: normal;
user-select: none;
cursor: default;
+ margin: 0 2px;
}
.operator-formula-component__symbol {
diff --git a/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js b/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js
deleted file mode 100644
index e6e7337cc3..0000000000
--- a/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js
+++ /dev/null
@@ -1,412 +0,0 @@
-/**
- * @fileoverview Shared utilities for formula editor extensions
- * This module provides common functionality for detecting strings, functions,
- * and other syntactic elements in formula text for use across multiple Tiptap extensions.
- */
-
-// ============================================================================
-// String Detection Utilities
-// ============================================================================
-
-/**
- * Finds all closed string literal ranges in the document content
- * @param {Array} content - Array of content items with {char, type, docPos, ...}
- * @returns {Array} Array of {start, end} ranges for closed strings
- */
-export const findClosedStringRanges = (content) => {
- const ranges = []
- let i = 0
-
- while (i < content.length) {
- if (content[i].type !== 'text') {
- i++
- continue
- }
-
- const ch = content[i].char
- if (ch === '"' || ch === "'") {
- const quoteChar = ch
- const startIdx = i
- let escaped = false
- i++
-
- // Find the closing quote
- while (i < content.length) {
- if (content[i].type !== 'text') {
- i++
- continue
- }
-
- const currentChar = content[i].char
-
- if (escaped) {
- escaped = false
- i++
- continue
- }
-
- if (currentChar === '\\') {
- escaped = true
- i++
- continue
- }
-
- if (currentChar === quoteChar) {
- // Found closing quote
- ranges.push({ start: startIdx, end: i })
- i++
- break
- }
-
- i++
- }
- } else {
- i++
- }
- }
-
- return ranges
-}
-
-/**
- * Checks if a position is inside a closed string literal
- * @param {Array} content - Array of content items
- * @param {number} index - Position to check
- * @param {Array} stringRanges - Pre-computed closed string ranges
- * @returns {boolean} True if position is inside a closed string
- */
-export const isInsideClosedString = (content, index, stringRanges) => {
- return stringRanges.some((range) => index > range.start && index < range.end)
-}
-
-/**
- * Checks if we're currently after an unclosed quote
- * @param {Array} content - Array of content items with {char, type, ...}
- * @param {number} index - Position to check
- * @returns {boolean} True if there's an unclosed quote before this position
- */
-export const isAfterUnclosedQuote = (content, index) => {
- let inSingleQuote = false
- let inDoubleQuote = false
- let escaped = false
-
- for (let idx = 0; idx < index; idx++) {
- if (content[idx].type !== 'text') continue
- const ch = content[idx].char
-
- if (escaped) {
- escaped = false
- continue
- }
-
- if (ch === '\\') {
- escaped = true
- continue
- }
-
- if (ch === "'" && !inDoubleQuote) {
- inSingleQuote = !inSingleQuote
- } else if (ch === '"' && !inSingleQuote) {
- inDoubleQuote = !inDoubleQuote
- }
- }
-
- return inSingleQuote || inDoubleQuote
-}
-
-// ============================================================================
-// String Detection for NodeMap (used by FunctionDeletionExtension)
-// ============================================================================
-
-/**
- * Finds all closed string literal ranges in a nodeMap structure
- * @param {Array} nodeMap - Array of nodes with {pos, text, isText, ...}
- * @returns {Array} Array of {start, end} position ranges for closed strings
- */
-export const findClosedStringRangesInNodeMap = (nodeMap) => {
- const ranges = []
-
- for (const item of nodeMap) {
- if (item.isText && item.text) {
- let i = 0
- while (i < item.text.length) {
- const ch = item.text[i]
- const charPos = item.pos + i
-
- if (ch === '"' || ch === "'") {
- const quoteChar = ch
- const startPos = charPos
- let escaped = false
- i++
-
- // Find the closing quote in this or subsequent nodes
- let found = false
- for (
- let nodeIdx = nodeMap.indexOf(item);
- nodeIdx < nodeMap.length;
- nodeIdx++
- ) {
- const searchItem = nodeMap[nodeIdx]
- if (!searchItem.isText || !searchItem.text) {
- if (nodeIdx > nodeMap.indexOf(item)) break
- continue
- }
-
- const startIdx = nodeIdx === nodeMap.indexOf(item) ? i : 0
- for (let k = startIdx; k < searchItem.text.length; k++) {
- const currentChar = searchItem.text[k]
- const currentCharPos = searchItem.pos + k
-
- if (escaped) {
- escaped = false
- continue
- }
-
- if (currentChar === '\\') {
- escaped = true
- continue
- }
-
- if (currentChar === quoteChar) {
- ranges.push({ start: startPos, end: currentCharPos })
- i = nodeIdx === nodeMap.indexOf(item) ? k + 1 : item.text.length
- found = true
- break
- }
- }
-
- if (found) break
- }
-
- if (!found) {
- // No closing quote found, skip to next char
- break
- }
- } else {
- i++
- }
- }
- }
- }
-
- return ranges
-}
-
-/**
- * Checks if a position is inside a closed string literal (nodeMap version)
- * @param {Array} nodeMap - Array of nodes
- * @param {number} targetPos - Document position to check
- * @param {Array} stringRanges - Pre-computed closed string ranges
- * @returns {boolean} True if position is inside a closed string
- */
-export const isInsideClosedStringInNodeMap = (
- nodeMap,
- targetPos,
- stringRanges
-) => {
- return stringRanges.some(
- (range) => targetPos > range.start && targetPos < range.end
- )
-}
-
-/**
- * Checks if we're after an unclosed quote (nodeMap version)
- * @param {Array} nodeMap - Array of nodes with {pos, text, isText, ...}
- * @param {number} targetPos - Document position to check
- * @returns {boolean} True if there's an unclosed quote before this position
- */
-export const isAfterUnclosedQuoteInNodeMap = (nodeMap, targetPos) => {
- let inSingleQuote = false
- let inDoubleQuote = false
- let escaped = false
-
- for (const item of nodeMap) {
- if (item.isText && item.text) {
- for (let idx = 0; idx < item.text.length; idx++) {
- const currentPos = item.pos + idx
-
- if (currentPos >= targetPos) {
- return inSingleQuote || inDoubleQuote
- }
-
- const ch = item.text[idx]
-
- if (escaped) {
- escaped = false
- continue
- }
-
- if (ch === '\\') {
- escaped = true
- continue
- }
-
- if (ch === "'" && !inDoubleQuote) {
- inSingleQuote = !inSingleQuote
- } else if (ch === '"' && !inSingleQuote) {
- inDoubleQuote = !inDoubleQuote
- }
- }
- }
- }
-
- return inSingleQuote || inDoubleQuote
-}
-
-// ============================================================================
-// String Detection for Plain Text (used by FunctionAutoCompleteExtension)
-// ============================================================================
-
-/**
- * Checks if cursor is inside a string literal (closed or after unclosed quote)
- * Works on plain text strings (simpler version for autocomplete)
- * @param {string} text - The text to analyze
- * @returns {boolean} True if inside or after an unclosed string
- */
-export const isInsideStringInText = (text) => {
- const ranges = []
- let i = 0
-
- // Find all closed string ranges
- while (i < text.length) {
- const ch = text[i]
-
- if (ch === '"' || ch === "'") {
- const quoteChar = ch
- const startIdx = i
- let escaped = false
- i++
-
- // Find the closing quote
- while (i < text.length) {
- const currentChar = text[i]
-
- if (escaped) {
- escaped = false
- i++
- continue
- }
-
- if (currentChar === '\\') {
- escaped = true
- i++
- continue
- }
-
- if (currentChar === quoteChar) {
- // Found closing quote
- ranges.push({ start: startIdx, end: i })
- i++
- break
- }
-
- i++
- }
- } else {
- i++
- }
- }
-
- // Check if the last position is inside any closed string range
- const lastPos = text.length - 1
- if (ranges.some((range) => lastPos > range.start && lastPos < range.end)) {
- return true
- }
-
- // Also check if we're after an unclosed quote
- let inSingleQuote = false
- let inDoubleQuote = false
- let escaped = false
-
- for (let idx = 0; idx < text.length; idx++) {
- const ch = text[idx]
-
- if (escaped) {
- escaped = false
- continue
- }
-
- if (ch === '\\') {
- escaped = true
- continue
- }
-
- if (ch === "'" && !inDoubleQuote) {
- inSingleQuote = !inSingleQuote
- } else if (ch === '"' && !inSingleQuote) {
- inDoubleQuote = !inDoubleQuote
- }
- }
-
- return inSingleQuote || inDoubleQuote
-}
-
-// ============================================================================
-// Pattern Matching
-// ============================================================================
-
-/**
- * Checks if a pattern matches at a given position in the content
- * @param {Array} content - Array of content items
- * @param {number} index - Starting position
- * @param {string} pattern - Pattern to match
- * @param {boolean} checkWordBoundary - If true, ensures the match is at a word boundary
- * @returns {boolean} True if pattern matches at this position
- */
-export const matchesAt = (
- content,
- index,
- pattern,
- checkWordBoundary = false
-) => {
- // If checkWordBoundary is true, verify the character before is not alphanumeric
- if (checkWordBoundary && index > 0) {
- const prevItem = content[index - 1]
- if (prevItem && prevItem.type === 'text') {
- const prevChar = prevItem.char
- // Check if previous character is alphanumeric or underscore
- if (/[a-zA-Z0-9_]/.test(prevChar)) {
- return false
- }
- }
- }
-
- for (let i = 0; i < pattern.length; i++) {
- if (
- index + i >= content.length ||
- content[index + i].type !== 'text' ||
- content[index + i].char.toLowerCase() !== pattern[i].toLowerCase()
- ) {
- return false
- }
- }
- return true
-}
-
-/**
- * Finds the closing parenthesis for a function, ignoring parentheses in closed strings
- * @param {Array} documentContent - Array of content items
- * @param {number} startIndex - Index after the opening parenthesis
- * @param {Array} stringRanges - Pre-computed closed string ranges
- * @returns {number} Index of closing parenthesis, or -1 if not found
- */
-export const findClosingParen = (documentContent, startIndex, stringRanges) => {
- let parenCount = 1
- let k = startIndex
-
- while (k < documentContent.length && parenCount > 0) {
- if (documentContent[k].type === 'text') {
- // Only count parentheses that are not inside CLOSED strings
- if (!isInsideClosedString(documentContent, k, stringRanges)) {
- if (documentContent[k].char === '(') {
- parenCount++
- } else if (documentContent[k].char === ')') {
- parenCount--
- }
- }
- }
- k++
- }
-
- return parenCount === 0 ? k - 1 : -1
-}
diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue
index fc335d9553..8d669fbd60 100644
--- a/web-frontend/modules/core/components/formula/FormulaInputField.vue
+++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue
@@ -36,28 +36,34 @@
diff --git a/web-frontend/modules/core/components/formula/extensions/ArrowKeyNavigationExtension.js b/web-frontend/modules/core/components/formula/extensions/ArrowKeyNavigationExtension.js
new file mode 100644
index 0000000000..f6dbd8a052
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/extensions/ArrowKeyNavigationExtension.js
@@ -0,0 +1,225 @@
+import { Extension } from '@tiptap/core'
+import { TextSelection } from 'prosemirror-state'
+
+export const ArrowKeyNavigationExtension = Extension.create({
+ name: 'arrowKeyNavigation',
+
+ addKeyboardShortcuts() {
+ const skippableNodes = [
+ 'get-formula-component',
+ 'function-formula-component',
+ 'operator-formula-component',
+ 'function-argument-comma',
+ 'function-closing-paren',
+ 'group-opening-paren',
+ 'group-closing-paren',
+ ]
+
+ /**
+ * Skips closing paren after function with no args when going right
+ * @param {object} state - Editor state
+ * @param {number} pos - Current position after skipping the function
+ * @param {object} functionNode - The function node that was skipped
+ * @returns {number} - New position after potentially skipping closing paren
+ */
+ const skipClosingParenForNoArgFunction = (state, pos, functionNode) => {
+ if (functionNode.type.name === 'function-formula-component') {
+ const $pos = state.doc.resolve(pos)
+ const followingNode = $pos.nodeAfter
+
+ if (
+ followingNode &&
+ followingNode.type.name === 'function-closing-paren'
+ ) {
+ return pos + followingNode.nodeSize
+ }
+ }
+ return pos
+ }
+
+ /**
+ * Skips space and ZWS after minus operator when going right
+ * @param {object} state - Editor state
+ * @param {number} pos - Current position after skipping the operator
+ * @param {object} operatorNode - The operator node that was skipped
+ * @returns {number} - New position after skipping space and ZWS
+ */
+ const skipSpaceAfterMinusOperator = (state, pos, operatorNode) => {
+ if (
+ operatorNode.type.name === 'operator-formula-component' &&
+ operatorNode.attrs.operatorSymbol === '-'
+ ) {
+ const $pos = state.doc.resolve(pos)
+ const followingNode = $pos.nodeAfter
+
+ // Check if the following text starts with a space
+ // (it might be in a mixed text node like space+2+ZWS)
+ if (
+ followingNode &&
+ followingNode.isText &&
+ followingNode.text?.startsWith(' ')
+ ) {
+ // Skip just the space character (1 position)
+ pos += 1
+ }
+ }
+
+ return pos
+ }
+
+ /**
+ * Checks if we should skip a space before a minus operator when going left
+ * @param {object} state - Editor state
+ * @param {number} pos - Current position
+ * @param {object} spaceNode - The space node to check
+ * @returns {boolean} - True if space should be skipped
+ */
+ const shouldSkipSpaceBeforeMinusOperator = (state, pos, spaceNode) => {
+ const posBeforeSpace = pos - spaceNode.nodeSize
+ const $beforeSpace = state.doc.resolve(posBeforeSpace)
+ const nodeBeforeSpace = $beforeSpace.nodeBefore
+
+ return (
+ nodeBeforeSpace &&
+ nodeBeforeSpace.type.name === 'operator-formula-component' &&
+ nodeBeforeSpace.attrs?.operatorSymbol === '-'
+ )
+ }
+
+ /**
+ * Skips function node before closing paren when going left (no args function)
+ * @param {object} state - Editor state
+ * @param {number} pos - Current position after skipping the closing paren
+ * @param {object} closingParenNode - The closing paren node that was skipped
+ * @returns {number} - New position after potentially skipping function node
+ */
+ const skipFunctionBeforeClosingParen = (state, pos, closingParenNode) => {
+ if (closingParenNode.type.name === 'function-closing-paren') {
+ const $pos = state.doc.resolve(pos)
+ const precedingNode = $pos.nodeBefore
+
+ if (
+ precedingNode &&
+ precedingNode.type.name === 'function-formula-component'
+ ) {
+ return pos - precedingNode.nodeSize
+ }
+ }
+ return pos
+ }
+
+ return {
+ ArrowRight: () => {
+ const { state, dispatch } = this.editor.view
+ const { selection } = state
+
+ if (!selection.empty || selection.from === state.doc.content.size) {
+ return false
+ }
+
+ const { from } = selection
+ let pos = from
+ let moved = false
+ let skippedNode = false
+
+ // Skip consecutive ZWS, then ONE skippable node, then consecutive ZWS again
+ while (pos < state.doc.content.size) {
+ const $pos = state.doc.resolve(pos)
+ const nextNode = $pos.nodeAfter
+
+ if (!nextNode) break
+
+ // Always skip ZWS
+ if (nextNode.isText && nextNode.text === '\u200B') {
+ pos += nextNode.nodeSize
+ moved = true
+ continue
+ }
+
+ // Skip only ONE skippable node
+ if (!skippedNode && skippableNodes.includes(nextNode.type.name)) {
+ pos += nextNode.nodeSize
+ moved = true
+ skippedNode = true
+
+ // Handle special cases after skipping a node
+ pos = skipClosingParenForNoArgFunction(state, pos, nextNode)
+ pos = skipSpaceAfterMinusOperator(state, pos, nextNode)
+
+ continue
+ }
+
+ // If we already skipped a node, or it's neither ZWS nor skippable, stop
+ break
+ }
+
+ if (moved) {
+ dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos)))
+ return true
+ }
+
+ return false
+ },
+
+ ArrowLeft: () => {
+ const { state, dispatch } = this.editor.view
+ const { selection } = state
+
+ if (!selection.empty || selection.from === 0) {
+ return false
+ }
+
+ const { from } = selection
+ let pos = from
+ let moved = false
+ let skippedNode = false
+
+ // Skip consecutive ZWS, then ONE skippable node, then consecutive ZWS again
+ while (pos > 0) {
+ const $pos = state.doc.resolve(pos)
+ const prevNode = $pos.nodeBefore
+
+ if (!prevNode) break
+
+ // Always skip ZWS
+ if (prevNode.isText && prevNode.text === '\u200B') {
+ pos -= prevNode.nodeSize
+ moved = true
+ continue
+ }
+
+ // Skip space if it's after a minus operator
+ if (prevNode.isText && prevNode.text === ' ') {
+ if (shouldSkipSpaceBeforeMinusOperator(state, pos, prevNode)) {
+ pos -= prevNode.nodeSize
+ moved = true
+ continue
+ }
+ }
+
+ // Skip only ONE skippable node
+ if (!skippedNode && skippableNodes.includes(prevNode.type.name)) {
+ pos -= prevNode.nodeSize
+ moved = true
+ skippedNode = true
+
+ // Handle special cases after skipping a node
+ pos = skipFunctionBeforeClosingParen(state, pos, prevNode)
+
+ continue
+ }
+
+ // If we already skipped a node, or it's neither ZWS nor skippable, stop
+ break
+ }
+
+ if (moved) {
+ dispatch(state.tr.setSelection(TextSelection.create(state.doc, pos)))
+ return true
+ }
+
+ return false
+ },
+ }
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/ContextManagementExtension.js b/web-frontend/modules/core/components/formula/extensions/ContextManagementExtension.js
similarity index 89%
rename from web-frontend/modules/core/components/formula/ContextManagementExtension.js
rename to web-frontend/modules/core/components/formula/extensions/ContextManagementExtension.js
index b0166a5edd..e4013c47aa 100644
--- a/web-frontend/modules/core/components/formula/ContextManagementExtension.js
+++ b/web-frontend/modules/core/components/formula/extensions/ContextManagementExtension.js
@@ -243,40 +243,4 @@ export const ContextManagementExtension = Extension.create({
this.storage.clickOutsideEventCancel = null
}
},
-
- getContextConfig() {
- const { vueComponent } = this.options
- // Read directly from Vue component to get reactive value
- const contextPosition =
- vueComponent?.contextPosition ?? this.options.contextPosition
-
- switch (contextPosition) {
- case 'left':
- return {
- vertical: 'top',
- horizontal: 'left',
- needsDynamicOffset: true,
- }
- case 'bottom':
- return {
- vertical: 'bottom',
- horizontal: 'left',
- verticalOffset: 10,
- horizontalOffset: 0,
- }
- case 'right':
- return {
- vertical: 'top',
- horizontal: 'left',
- needsDynamicOffset: true,
- }
- default:
- return {
- vertical: 'bottom',
- horizontal: 'left',
- verticalOffset: 0,
- horizontalOffset: -400,
- }
- }
- },
})
diff --git a/web-frontend/modules/core/components/formula/FormulaClipboardHandler.js b/web-frontend/modules/core/components/formula/extensions/FormulaClipboardHandler.js
similarity index 100%
rename from web-frontend/modules/core/components/formula/FormulaClipboardHandler.js
rename to web-frontend/modules/core/components/formula/extensions/FormulaClipboardHandler.js
diff --git a/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js b/web-frontend/modules/core/components/formula/extensions/FormulaNodes.js
similarity index 54%
rename from web-frontend/modules/core/components/formula/FormulaInsertionExtension.js
rename to web-frontend/modules/core/components/formula/extensions/FormulaNodes.js
index f938e14b61..33b70c56d2 100644
--- a/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js
+++ b/web-frontend/modules/core/components/formula/extensions/FormulaNodes.js
@@ -45,6 +45,7 @@ export const FunctionFormulaComponentNode = Node.create({
name: 'function-formula-component',
group: 'inline',
inline: true,
+ atom: true,
draggable: false,
selectable: false,
@@ -59,6 +60,9 @@ export const FunctionFormulaComponentNode = Node.create({
isSelected: {
default: false,
},
+ hasNoArgs: {
+ default: false,
+ },
}
},
@@ -70,11 +74,12 @@ export const FunctionFormulaComponentNode = Node.create({
]
},
- renderHTML({ HTMLAttributes }) {
- return [
- 'span',
- mergeAttributes(HTMLAttributes, { 'data-formula-component': this.name }),
- ]
+ renderHTML({ node, HTMLAttributes }) {
+ const attrs = { 'data-formula-component': this.name }
+ if (node.attrs.hasNoArgs) {
+ attrs['data-no-args'] = 'true'
+ }
+ return ['span', mergeAttributes(HTMLAttributes, attrs)]
},
addNodeView() {
@@ -87,6 +92,7 @@ export const FunctionArgumentCommaNode = Node.create({
name: 'function-argument-comma',
group: 'inline',
inline: true,
+ atom: true,
draggable: false,
selectable: false,
@@ -115,9 +121,18 @@ export const FunctionClosingParenNode = Node.create({
name: 'function-closing-paren',
group: 'inline',
inline: true,
+ atom: true,
draggable: false,
selectable: false,
+ addAttributes() {
+ return {
+ noArgs: {
+ default: false,
+ },
+ }
+ },
+
parseHTML() {
return [
{
@@ -126,12 +141,70 @@ export const FunctionClosingParenNode = Node.create({
]
},
+ renderHTML({ node, HTMLAttributes }) {
+ const attrs = {
+ 'data-formula-closing-paren': 'true',
+ class: 'formula-input-field__parenthesis',
+ }
+ if (node.attrs.noArgs) {
+ attrs['data-no-args'] = 'true'
+ }
+ return ['span', mergeAttributes(HTMLAttributes, attrs), ')']
+ },
+})
+
+// Atomic opening parenthesis node for grouping
+export const GroupOpeningParenNode = Node.create({
+ name: 'group-opening-paren',
+ group: 'inline',
+ inline: true,
+ atom: true,
+ draggable: false,
+ selectable: false,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-group-opening-paren="true"]',
+ },
+ ]
+ },
+
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(HTMLAttributes, {
- 'data-formula-closing-paren': 'true',
- class: 'formula-input-field__parenthesis',
+ 'data-group-opening-paren': 'true',
+ class: 'formula-input-field__group-parenthesis',
+ }),
+ '(',
+ ]
+ },
+})
+
+// Atomic closing parenthesis node for grouping
+export const GroupClosingParenNode = Node.create({
+ name: 'group-closing-paren',
+ group: 'inline',
+ inline: true,
+ atom: true,
+ draggable: false,
+ selectable: false,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span[data-group-closing-paren="true"]',
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'span',
+ mergeAttributes(HTMLAttributes, {
+ 'data-group-closing-paren': 'true',
+ class: 'formula-input-field__group-parenthesis',
}),
')',
]
@@ -143,6 +216,7 @@ export const OperatorFormulaComponentNode = Node.create({
name: 'operator-formula-component',
group: 'inline',
inline: true,
+ atom: true,
draggable: false,
selectable: false,
@@ -183,10 +257,20 @@ export const FormulaInsertionExtension = Extension.create({
insertDataComponent:
(path) =>
({ editor, commands }) => {
- commands.insertContent({
- type: 'get-formula-component',
- attrs: { path },
- })
+ commands.insertContent([
+ {
+ type: 'text',
+ text: '\u200B',
+ },
+ {
+ type: 'get-formula-component',
+ attrs: { path },
+ },
+ {
+ type: 'text',
+ text: '\u200B',
+ },
+ ])
commands.focus()
@@ -196,7 +280,7 @@ export const FormulaInsertionExtension = Extension.create({
(node) =>
({ editor, commands, state }) => {
const functionName = node.name
- const minArgs = node.signature?.minArgs || 1
+ const minArgs = node.signature?.minArgs || 0
// Get initial cursor position
const initialPos = state.selection.from
@@ -204,34 +288,54 @@ export const FormulaInsertionExtension = Extension.create({
// Build all content to insert
const contentToInsert = []
+ // Add ZWS before the function component
+ contentToInsert.push({ type: 'text', text: '\u200B' })
+
// Add function component
contentToInsert.push({
type: 'function-formula-component',
attrs: {
functionName,
+ hasNoArgs: minArgs === 0,
},
})
- // 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 argument placeholders if needed
+ if (minArgs > 0) {
+ contentToInsert.push({ type: 'text', text: '\u200B' }) // First argument
+ for (let i = 1; i < minArgs; i++) {
+ contentToInsert.push({ type: 'function-argument-comma' })
+ contentToInsert.push({ type: 'text', text: '\u200B' }) // Subsequent arguments
}
}
// Add closing parenthesis
contentToInsert.push({
type: 'function-closing-paren',
+ attrs: {
+ noArgs: minArgs === 0,
+ },
})
+ // Always add a ZWS after the whole function call
+ // CleanupZWSExtension will remove any consecutive ZWS automatically
+ contentToInsert.push({ type: 'text', text: '\u200B' })
+
// 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
+ // Position cursor:
+ // - If no arguments expected, place after closing paren (but before the final ZWS)
+ // - Otherwise, place right after the function component (in first argument slot)
+ let targetPos
+ if (minArgs === 0) {
+ // ZWS (1) + functionNode (1) + closingParenNode (1) = 3
+ // We place cursor at position 3, which is after the closing paren but before the final ZWS
+ targetPos = initialPos + 3
+ } else {
+ // ZWS (1) + functionNode (1) = 2 (in first argument slot)
+ targetPos = initialPos + 2
+ }
commands.setTextSelection({
from: targetPos,
@@ -247,13 +351,25 @@ export const FormulaInsertionExtension = Extension.create({
({ editor, commands }) => {
const operatorSymbol = node.signature.operator
- // Insert operator as an operator-formula-component node
- commands.insertContent({
- type: 'operator-formula-component',
- attrs: {
- operatorSymbol,
+ // Build content to insert
+ const contentToInsert = [
+ { type: 'text', text: '\u200B' },
+ {
+ type: 'operator-formula-component',
+ attrs: {
+ operatorSymbol,
+ },
},
- })
+ ]
+
+ // Add space after minus operator to distinguish from negative numbers
+ if (operatorSymbol === '-') {
+ contentToInsert.push({ type: 'text', text: ' ' })
+ }
+
+ contentToInsert.push({ type: 'text', text: '\u200B' })
+
+ commands.insertContent(contentToInsert)
commands.focus()
diff --git a/web-frontend/modules/core/components/formula/FunctionDetectionExtension.js b/web-frontend/modules/core/components/formula/extensions/FunctionDetectionExtension.js
similarity index 79%
rename from web-frontend/modules/core/components/formula/FunctionDetectionExtension.js
rename to web-frontend/modules/core/components/formula/extensions/FunctionDetectionExtension.js
index 7bea21c777..4e576c93f7 100644
--- a/web-frontend/modules/core/components/formula/FunctionDetectionExtension.js
+++ b/web-frontend/modules/core/components/formula/extensions/FunctionDetectionExtension.js
@@ -8,13 +8,13 @@ export const FunctionDetectionExtension = Extension.create({
addOptions() {
return {
functionNames: [],
- vueComponent: null,
+ functionDefinitions: {},
}
},
addProseMirrorPlugins() {
const functionNames = this.options.functionNames
- const vueComponent = this.options.vueComponent
+ const functionDefinitions = this.options.functionDefinitions
function handleOpeningParenthesis(view, from, to) {
const { state } = view
@@ -37,15 +37,8 @@ export const FunctionDetectionExtension = Extension.create({
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()
- )
+ // Find the function definition using the pre-computed map
+ const functionDef = functionDefinitions[functionName.toLowerCase()]
if (functionDef) {
const signature = functionDef.signature || {}
@@ -57,37 +50,58 @@ export const FunctionDetectionExtension = Extension.create({
// Build all nodes to insert
const nodesToInsert = []
+ // Add ZWS before the function component
+ nodesToInsert.push(state.schema.text('\u200B'))
+
// Insert function node (atomic)
const functionNode = state.schema.nodes[
'function-formula-component'
].create({
functionName,
+ hasNoArgs: minArgs === 0,
})
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 =
+ if (minArgs > 0) {
+ nodesToInsert.push(state.schema.text('\u200B')) // First argument
+ for (let i = 1; i < minArgs; i++) {
+ nodesToInsert.push(
state.schema.nodes['function-argument-comma'].create()
- nodesToInsert.push(commaNode)
+ )
+ nodesToInsert.push(state.schema.text('\u200B')) // Subsequent arguments
}
}
// Insert the closing parenthesis as atomic node
- const closingParenNode =
- state.schema.nodes['function-closing-paren'].create()
+ const closingParenNode = state.schema.nodes[
+ 'function-closing-paren'
+ ].create({
+ noArgs: minArgs === 0,
+ })
nodesToInsert.push(closingParenNode)
+ // Always add a ZWS after the whole function call
+ // CleanupZWSExtension will remove any consecutive ZWS automatically
+ nodesToInsert.push(state.schema.text('\u200B'))
+
// 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
+ // Position cursor:
+ // - If no arguments expected, place after closing paren (but before the final ZWS)
+ // - Otherwise, place right after the function component (in first argument slot)
+ let cursorPos
+ if (minArgs === 0) {
+ // ZWS (1) + functionNode (1) + closingParenNode (1) = 3
+ // We place cursor at position 3, which is after the closing paren but before the final ZWS
+ cursorPos = functionStart + 3
+ } else {
+ // ZWS (1) + functionNode (1) = 2 (in first argument slot)
+ cursorPos = functionStart + 2
+ }
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
@@ -117,15 +131,19 @@ export const FunctionDetectionExtension = Extension.create({
// Create transaction
const tr = state.tr
- // Create the atomic comma node
- const commaNode = state.schema.nodes['function-argument-comma'].create()
+ // Create the atomic comma node and a ZWS for the next argument
+ const nodesToInsert = [
+ state.schema.nodes['function-argument-comma'].create(),
+ state.schema.text('\u200B'),
+ ]
+ const fragment = Fragment.from(nodesToInsert)
// Replace the typed comma with the atomic node
- tr.replaceWith(from, to, commaNode)
+ tr.replaceWith(from, to, fragment)
- // Position cursor after the comma
+ // Position cursor after the comma, in the new ZWS slot
const cursorPos = from + 1
- tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos)))
+ tr.setSelection(TextSelection.create(tr.doc, cursorPos))
view.dispatch(tr)
return true
diff --git a/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js b/web-frontend/modules/core/components/formula/extensions/FunctionHelpTooltipExtension.js
similarity index 81%
rename from web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js
rename to web-frontend/modules/core/components/formula/extensions/FunctionHelpTooltipExtension.js
index dd87a4a532..e8ccc37776 100644
--- a/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js
+++ b/web-frontend/modules/core/components/formula/extensions/FunctionHelpTooltipExtension.js
@@ -9,6 +9,7 @@ export const FunctionHelpTooltipExtension = Extension.create({
addOptions() {
return {
vueComponent: null,
+ functionDefinitions: {},
selector: '.function-name-highlight',
showDelay: 120,
hideDelay: 60,
@@ -16,7 +17,13 @@ export const FunctionHelpTooltipExtension = Extension.create({
},
addProseMirrorPlugins() {
- const { vueComponent, selector, showDelay, hideDelay } = this.options
+ const {
+ vueComponent,
+ functionDefinitions,
+ selector,
+ showDelay,
+ hideDelay,
+ } = this.options
let lastEl = null
let lastName = null
let showTimer = null
@@ -24,21 +31,7 @@ export const FunctionHelpTooltipExtension = Extension.create({
const findFunctionNodeByName = (name) => {
const needle = (name || '').toLowerCase()
- const walk = (nodes) => {
- for (const n of nodes || []) {
- if (
- n.type === 'function' &&
- typeof n.name === 'string' &&
- n.signature
- ) {
- if (n.name.toLowerCase() === needle) return n
- }
- const hit = walk(n.nodes)
- if (hit) return hit
- }
- return null
- }
- return walk(vueComponent?.nodesHierarchy || [])
+ return functionDefinitions[needle] || null
}
const showTooltip = (el, fname) => {
diff --git a/web-frontend/modules/core/components/formula/extensions/GroupDetectionExtension.js b/web-frontend/modules/core/components/formula/extensions/GroupDetectionExtension.js
new file mode 100644
index 0000000000..949d382455
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/extensions/GroupDetectionExtension.js
@@ -0,0 +1,147 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
+import { Fragment } from '@tiptap/pm/model'
+
+export const GroupDetectionExtension = Extension.create({
+ name: 'groupDetection',
+
+ addOptions() {
+ return {
+ functionNames: [],
+ }
+ },
+
+ addProseMirrorPlugins() {
+ const functionNames = this.options.functionNames
+
+ function handleOpeningParenthesis(view, from, to) {
+ const { state } = view
+ const { doc } = state
+
+ // Check if we should create a group parenthesis
+ // A group parenthesis is created when:
+ // - The previous text does NOT match a known function name
+
+ const textBefore = doc.textBetween(Math.max(0, from - 50), from, ',')
+
+ // If we have function names, check if the text ends with one
+ if (functionNames.length > 0) {
+ const functionPattern = new RegExp(
+ `(^|[^a-zA-Z0-9_])(${functionNames.join('|')})(\\s*)$`,
+ 'i'
+ )
+
+ if (functionPattern.test(textBefore)) {
+ // This is a function call, let FunctionDetectionExtension handle it
+ return false
+ }
+ }
+
+ // This is a grouping parenthesis
+ const tr = state.tr
+
+ // Create the group opening paren node with a ZWS after
+ const nodesToInsert = [
+ state.schema.text('\u200B'),
+ state.schema.nodes['group-opening-paren'].create(),
+ state.schema.text('\u200B'),
+ ]
+
+ const fragment = Fragment.from(nodesToInsert)
+ tr.replaceWith(from, to, fragment)
+
+ // Position cursor after the opening paren (in the ZWS)
+ const cursorPos = from + 2
+ tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+
+ view.dispatch(tr)
+ return true
+ }
+
+ function handleClosingParenthesis(view, from, to) {
+ const { state } = view
+ const { doc } = state
+
+ // Check if we're closing a group by counting parentheses
+ if (!isClosingGroup(doc, from)) {
+ // Let other extensions handle function closing
+ return false
+ }
+
+ // This is closing a group
+ const tr = state.tr
+
+ // Create the group closing paren node
+ const closingParenNode =
+ state.schema.nodes['group-closing-paren'].create()
+
+ tr.replaceWith(from, to, closingParenNode)
+
+ // Position cursor after the closing paren
+ const cursorPos = from + 1
+ tr.setSelection(TextSelection.near(tr.doc.resolve(cursorPos)))
+
+ view.dispatch(tr)
+ return true
+ }
+
+ function isClosingGroup(doc, pos) {
+ // Count parentheses to determine if we're closing a group
+ let parenCount = 0
+ let foundGroupOpening = false
+
+ // Find the start of the wrapper
+ const $pos = doc.resolve(pos)
+ let wrapperStart = 0
+ for (let d = $pos.depth; d > 0; d--) {
+ if ($pos.node(d).type.name === 'wrapper') {
+ wrapperStart = $pos.start(d)
+ break
+ }
+ }
+
+ // Traverse from wrapper start to current position
+ doc.nodesBetween(wrapperStart, pos, (node, nodePos) => {
+ if (nodePos >= pos) return false
+
+ if (node.type.name === 'function-formula-component') {
+ parenCount = 1
+ } else if (node.type.name === 'group-opening-paren') {
+ foundGroupOpening = true
+ parenCount++
+ } else if (node.type.name === 'group-closing-paren') {
+ parenCount--
+ if (parenCount === 0) {
+ foundGroupOpening = false
+ }
+ } else if (node.type.name === 'function-closing-paren') {
+ parenCount--
+ }
+ })
+
+ // We're closing a group if we have an open group paren
+ return foundGroupOpening && parenCount > 0
+ }
+
+ return [
+ new Plugin({
+ key: new PluginKey('groupDetection'),
+ props: {
+ handleTextInput(view, from, to, text) {
+ // Process opening parenthesis for group detection
+ if (text === '(') {
+ return handleOpeningParenthesis(view, from, to)
+ }
+
+ // Process closing parenthesis for group detection
+ if (text === ')') {
+ return handleClosingParenthesis(view, from, to)
+ }
+
+ return false
+ },
+ },
+ }),
+ ]
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/NodeSelectionExtension.js b/web-frontend/modules/core/components/formula/extensions/NodeSelectionExtension.js
similarity index 100%
rename from web-frontend/modules/core/components/formula/NodeSelectionExtension.js
rename to web-frontend/modules/core/components/formula/extensions/NodeSelectionExtension.js
diff --git a/web-frontend/modules/core/components/formula/OperatorDetectionExtension.js b/web-frontend/modules/core/components/formula/extensions/OperatorDetectionExtension.js
similarity index 65%
rename from web-frontend/modules/core/components/formula/OperatorDetectionExtension.js
rename to web-frontend/modules/core/components/formula/extensions/OperatorDetectionExtension.js
index 19c63df922..cb8d3bf12e 100644
--- a/web-frontend/modules/core/components/formula/OperatorDetectionExtension.js
+++ b/web-frontend/modules/core/components/formula/extensions/OperatorDetectionExtension.js
@@ -36,6 +36,52 @@ export const OperatorDetectionExtension = Extension.create({
return inString
}
+ /**
+ * Handles minus operator detection when space is typed
+ * Returns true if the minus was converted to an operator, false otherwise
+ */
+ function handleMinusOperatorWithSpace(view, from, to) {
+ const { state } = view
+ const { doc, schema, tr } = state
+
+ // Get the position and node before cursor
+ const $pos = doc.resolve(from)
+ const nodeBefore = $pos.nodeBefore
+
+ // Check if the node before is text ending with '-'
+ if (
+ nodeBefore &&
+ nodeBefore.isText &&
+ nodeBefore.text &&
+ nodeBefore.text.endsWith('-')
+ ) {
+ // The space will be consumed as part of the operator replacement
+ // Replace the minus at position from-1 to from (the minus character)
+ // and insert operator + space + ZWS
+ const minusStartPos = from - 1
+
+ const nodesToInsert = [
+ schema.nodes['operator-formula-component'].create({
+ operatorSymbol: '-',
+ }),
+ schema.text(' '),
+ schema.text('\u200B'),
+ ]
+
+ const fragment = Fragment.from(nodesToInsert)
+ tr.replaceWith(minusStartPos, from, fragment)
+
+ // Position cursor after the space, in the ZWS slot
+ const cursorPos = minusStartPos + 2
+ tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+
+ view.dispatch(tr)
+ return true
+ }
+
+ return false
+ }
+
function shouldConvertOperator(view, from, to, typedChar) {
const { state } = view
const { doc } = state
@@ -102,14 +148,17 @@ export const OperatorDetectionExtension = Extension.create({
operatorSymbol: operatorText,
})
- // Create the fragment with just the operator node (no spaces)
- const fragment = Fragment.from([operatorNode])
+ // Build nodes to insert (operator + ZWS)
+ // Note: for minus, space is handled separately in space detection
+ const nodesToInsert = [operatorNode, schema.text('\u200B')]
+
+ const fragment = Fragment.from(nodesToInsert)
// Replace from startPos to endPos with the fragment
tr.replaceWith(startPos, endPos, fragment)
- // Position cursor after the operator
- const cursorPos = startPos + fragment.size
+ // Position cursor after the operator, in the ZWS slot
+ const cursorPos = startPos + 1
tr.setSelection(TextSelection.create(tr.doc, cursorPos))
view.dispatch(tr)
@@ -129,7 +178,18 @@ export const OperatorDetectionExtension = Extension.create({
key: new PluginKey('operatorDetection'),
props: {
handleTextInput(view, from, to, text) {
- // Only handle operator characters
+ // Special handling for minus operator
+ // Don't convert '-' immediately to allow typing negative numbers
+ if (text === '-') {
+ return false
+ }
+
+ // If space is typed, check if it's after a minus sign to convert it
+ if (text === ' ') {
+ return handleMinusOperatorWithSpace(view, from, to)
+ }
+
+ // Only handle operator characters (excluding minus and space)
if (!operatorChars.has(text)) {
return false
}
diff --git a/web-frontend/modules/core/components/formula/extensions/SmartDeletionExtension.js b/web-frontend/modules/core/components/formula/extensions/SmartDeletionExtension.js
new file mode 100644
index 0000000000..ccc4a1fdfb
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/extensions/SmartDeletionExtension.js
@@ -0,0 +1,144 @@
+import { Extension } from '@tiptap/core'
+
+/**
+ * Extension that provides smart deletion behavior for atomic nodes.
+ * When deleting (Backspace or Delete) near an atomic node with adjacent ZWS,
+ * both the node and the ZWS are deleted together in a single keystroke.
+ */
+export const SmartDeletionExtension = Extension.create({
+ name: 'smartDeletion',
+
+ addKeyboardShortcuts() {
+ const atomicNodes = [
+ 'get-formula-component',
+ 'function-formula-component',
+ 'operator-formula-component',
+ 'function-argument-comma',
+ 'function-closing-paren',
+ 'group-opening-paren',
+ 'group-closing-paren',
+ ]
+
+ /**
+ * Tries to delete a minus operator with its trailing space
+ * @returns {object|null} - Deletion range {from, to} or null if not applicable
+ */
+ const tryDeleteMinusOperatorWithSpace = (state, from, isBackward) => {
+ const $pos = state.doc.resolve(from)
+ const adjacentNode = isBackward ? $pos.nodeBefore : $pos.nodeAfter
+
+ // Check if we're adjacent to a space
+ if (adjacentNode && adjacentNode.isText && adjacentNode.text === ' ') {
+ const posOtherSideSpace = isBackward
+ ? from - adjacentNode.nodeSize
+ : from + adjacentNode.nodeSize
+ const $otherSideSpace = state.doc.resolve(posOtherSideSpace)
+ const nodeOtherSideSpace = isBackward
+ ? $otherSideSpace.nodeBefore
+ : $otherSideSpace.nodeAfter
+
+ // Check if the other side is a minus operator
+ if (
+ nodeOtherSideSpace &&
+ nodeOtherSideSpace.type.name === 'operator-formula-component' &&
+ nodeOtherSideSpace.attrs.operatorSymbol === '-'
+ ) {
+ if (isBackward) {
+ // Backspace: delete operator + space, and ZWS after if present
+ const $afterSpace = state.doc.resolve(from)
+ const nodeAfterSpace = $afterSpace.nodeAfter
+
+ const deleteFrom = posOtherSideSpace - nodeOtherSideSpace.nodeSize
+ const deleteTo =
+ nodeAfterSpace &&
+ nodeAfterSpace.isText &&
+ nodeAfterSpace.text === '\u200B'
+ ? from + nodeAfterSpace.nodeSize
+ : from
+
+ return { from: deleteFrom, to: deleteTo }
+ }
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Handles smart deletion in a given direction
+ * @param {boolean} isBackward - true for Backspace, false for Delete
+ */
+ const handleSmartDeletion = (isBackward) => {
+ const { state, dispatch } = this.editor.view
+ const { selection } = state
+
+ // Check boundaries
+ if (!selection.empty) {
+ return false
+ }
+ if (isBackward && selection.from === 0) {
+ return false
+ }
+ if (!isBackward && selection.from === state.doc.content.size) {
+ return false
+ }
+
+ const { from } = selection
+
+ // Try to delete minus operator with space (special case)
+ const minusDeletion = tryDeleteMinusOperatorWithSpace(
+ state,
+ from,
+ isBackward
+ )
+ if (minusDeletion) {
+ const tr = state.tr
+ tr.delete(minusDeletion.from, minusDeletion.to)
+ dispatch(tr)
+ return true
+ }
+
+ const $pos = state.doc.resolve(from)
+ const adjacentNode = isBackward ? $pos.nodeBefore : $pos.nodeAfter
+
+ // Check if the adjacent node is a ZWS
+ if (
+ adjacentNode &&
+ adjacentNode.isText &&
+ adjacentNode.text === '\u200B'
+ ) {
+ // Get the position on the other side of the ZWS
+ const posOtherSideZWS = isBackward
+ ? from - adjacentNode.nodeSize
+ : from + adjacentNode.nodeSize
+ const $posOtherSide = state.doc.resolve(posOtherSideZWS)
+ const nodeOtherSide = isBackward
+ ? $posOtherSide.nodeBefore
+ : $posOtherSide.nodeAfter
+
+ // Check if the node on the other side is an atomic node
+ if (nodeOtherSide && atomicNodes.includes(nodeOtherSide.type.name)) {
+ // Delete both the ZWS and the atomic node
+ const tr = state.tr
+ const deleteFrom = isBackward
+ ? posOtherSideZWS - nodeOtherSide.nodeSize
+ : from
+ const deleteTo = isBackward
+ ? from
+ : posOtherSideZWS + nodeOtherSide.nodeSize
+
+ tr.delete(deleteFrom, deleteTo)
+ dispatch(tr)
+ return true
+ }
+ }
+
+ return false
+ }
+
+ return {
+ Backspace: () => handleSmartDeletion(true),
+ Delete: () => handleSmartDeletion(false),
+ }
+ },
+})
diff --git a/web-frontend/modules/core/components/formula/extensions/ZWSManagementExtension.js b/web-frontend/modules/core/components/formula/extensions/ZWSManagementExtension.js
new file mode 100644
index 0000000000..c77382a19f
--- /dev/null
+++ b/web-frontend/modules/core/components/formula/extensions/ZWSManagementExtension.js
@@ -0,0 +1,107 @@
+import { Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+
+/**
+ * Extension that manages Zero-Width Spaces (ZWS) in the formula editor.
+ * This extension ensures that:
+ * 1. There are no consecutive ZWS (cleanup)
+ * 2. Empty argument slots always have at least one ZWS (ensure)
+ */
+export const ZWSManagementExtension = Extension.create({
+ name: 'zwsManagement',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey('zwsManagement'),
+ appendTransaction(transactions, oldState, newState) {
+ const tr = newState.tr
+ let modified = false
+
+ // Phase 1: Clean up consecutive ZWS
+ newState.doc.descendants((node, pos) => {
+ if (node.isText && node.text) {
+ // Check if the text contains multiple consecutive ZWS
+ const text = node.text
+ if (text.includes('\u200B\u200B')) {
+ // Replace multiple consecutive ZWS with a single one
+ const cleanedText = text.replace(/\u200B+/g, '\u200B')
+ if (cleanedText !== text) {
+ tr.insertText(cleanedText, pos, pos + node.nodeSize)
+ modified = true
+ }
+ }
+ }
+ })
+
+ // Apply cleanup changes before checking for missing ZWS
+ const docAfterCleanup = modified ? tr.doc : newState.doc
+
+ // Phase 2: Ensure ZWS in empty argument slots
+ const argumentStartNodes = [
+ 'function-formula-component',
+ 'function-argument-comma',
+ 'operator-formula-component',
+ ]
+
+ const argumentEndNodes = [
+ 'function-argument-comma',
+ 'function-closing-paren',
+ ]
+
+ const needZWSBeforeNodes = [
+ 'operator-formula-component',
+ 'function-argument-comma',
+ 'function-closing-paren',
+ ]
+
+ docAfterCleanup.descendants((node, pos) => {
+ // Check for ZWS after argument start nodes
+ if (argumentStartNodes.includes(node.type.name)) {
+ const afterNodePos = pos + node.nodeSize
+ const $afterNode = docAfterCleanup.resolve(afterNodePos)
+ const nextNode = $afterNode.nodeAfter
+
+ // Check if the next node is an argument end node (empty argument)
+ if (nextNode && argumentEndNodes.includes(nextNode.type.name)) {
+ // Empty argument slot! Insert a ZWS
+ tr.insert(afterNodePos, newState.schema.text('\u200B'))
+ modified = true
+ } else if (!nextNode) {
+ // End of document after argument start node
+ tr.insert(afterNodePos, newState.schema.text('\u200B'))
+ modified = true
+ }
+ }
+
+ // Check for ZWS before nodes that need it
+ if (needZWSBeforeNodes.includes(node.type.name)) {
+ const $beforeNode = docAfterCleanup.resolve(pos)
+ const prevNode = $beforeNode.nodeBefore
+
+ // If the previous node is NOT a ZWS, we need to add one
+ if (
+ !prevNode ||
+ !(prevNode.isText && prevNode.text === '\u200B')
+ ) {
+ // Check if previous node is an atomic node that marks argument boundary
+ if (
+ !prevNode ||
+ prevNode.type.name === 'function-formula-component' ||
+ prevNode.type.name === 'function-argument-comma' ||
+ prevNode.type.name === 'operator-formula-component'
+ ) {
+ // Empty left argument! Insert a ZWS
+ tr.insert(pos, newState.schema.text('\u200B'))
+ modified = true
+ }
+ }
+ }
+ })
+
+ return modified ? tr : null
+ },
+ }),
+ ]
+ },
+})
diff --git a/web-frontend/modules/core/formula/index.js b/web-frontend/modules/core/formula/index.js
index b926b64e77..0322663dde 100644
--- a/web-frontend/modules/core/formula/index.js
+++ b/web-frontend/modules/core/formula/index.js
@@ -73,6 +73,11 @@ export const getFormulaFunctionsByCategory = (app, i18n = null) => {
// Group functions by category
for (const [functionName, registryItem] of Object.entries(functions)) {
+ // Filter out the 'get' function
+ if (functionName === 'get') {
+ continue
+ }
+
try {
// The registry might return instances instead of classes
let instance = null
diff --git a/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js
index b6a846de20..685241c423 100644
--- a/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js
+++ b/web-frontend/modules/core/formula/tiptap/fromTipTapVisitor.js
@@ -1,3 +1,5 @@
+const ZWS_MARKER = Symbol('zws_marker')
+
export class FromTipTapVisitor {
constructor(functions, mode = 'simple') {
this.functions = functions
@@ -18,6 +20,10 @@ export class FromTipTapVisitor {
return ','
case 'function-closing-paren':
return ')'
+ case 'group-opening-paren':
+ return '('
+ case 'group-closing-paren':
+ return ')'
case 'operator-formula-component':
return this.visitOperatorFormulaComponent(node)
case 'hardBreak':
@@ -32,7 +38,13 @@ export class FromTipTapVisitor {
return ''
}
- const nodeContents = node.content.map(this.visit.bind(this))
+ const nodeContents = node.content
+ .map(this.visit.bind(this))
+ .filter((c) => c !== ZWS_MARKER)
+
+ if (nodeContents.length === 0) {
+ return ''
+ }
if (nodeContents.length === 1) {
if (nodeContents[0] === "''") {
@@ -66,7 +78,8 @@ export class FromTipTapVisitor {
}
if (node.content.length === 1) {
- return this.visit(node.content[0])
+ const result = this.visit(node.content[0])
+ return result === ZWS_MARKER ? "''" : result
}
if (this.isFunctionCallPattern(node.content)) {
@@ -93,9 +106,22 @@ export class FromTipTapVisitor {
}
if (this.mode === 'simple') {
- return `concat(${node.content.map(this.visit.bind(this)).join(', ')})`
+ const parts = node.content
+ .map(this.visit.bind(this))
+ .filter((p) => p !== ZWS_MARKER)
+
+ if (parts.length === 0) {
+ return "''"
+ } else if (parts.length === 1) {
+ return parts[0]
+ } else {
+ return `concat(${parts.join(', ')})`
+ }
} else {
- return node.content.map(this.visit.bind(this)).join('')
+ const parts = node.content
+ .map(this.visit.bind(this))
+ .filter((p) => p !== ZWS_MARKER)
+ return parts.join('')
}
}
@@ -145,8 +171,11 @@ export class FromTipTapVisitor {
}
visitText(node) {
+ if (node.text === '\u200B') {
+ return ZWS_MARKER
+ }
// Remove zero-width spaces used for cursor positioning
- let cleanText = node.text.replace(/\u200B/g, '')
+ const cleanText = node.text.replace(/\u200B/g, '')
if (this.mode === 'simple') {
return `'${cleanText.replace(/'/g, "\\'")}'`
@@ -154,8 +183,8 @@ export class FromTipTapVisitor {
// 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
+ const cleanTextAdvanced = cleanText.replace(/\n/g, '\n')
+ return cleanTextAdvanced
}
visitFunction(node) {
@@ -176,6 +205,10 @@ export class FromTipTapVisitor {
visitOperatorFormulaComponent(node) {
const operatorSymbol = node.attrs?.operatorSymbol || ''
+ // Add space after minus operator to distinguish from negative numbers
+ if (operatorSymbol === '-') {
+ return '- '
+ }
return operatorSymbol
}
diff --git a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
index bf12077db2..9391b3154b 100644
--- a/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
+++ b/web-frontend/modules/core/formula/tiptap/toTipTapVisitor.js
@@ -15,34 +15,60 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor {
// In advanced mode, ensure all content is wrapped in a single wrapper
if (this.mode === 'advanced') {
const content = _.isArray(result) ? result : [result]
+ const flatContent = content.flatMap((item) => {
+ // Filter out null or undefined items
+ if (!item) return []
+
+ // If the item is an array (from functions without wrapper in advanced mode)
+ if (Array.isArray(item)) {
+ return item
+ }
+
+ // If the item is a wrapper, extract its content
+ if (item.type === 'wrapper' && item.content) {
+ return item.content
+ }
+
+ // Return the item if it has a type
+ return item.type ? [item] : []
+ })
+
+ // Ensure content starts with ZWS
+ const firstNode = flatContent[0]
+ if (
+ !firstNode ||
+ firstNode.type !== 'text' ||
+ firstNode.text !== '\u200B'
+ ) {
+ flatContent.unshift({ type: 'text', text: '\u200B' })
+ }
+
return {
type: 'doc',
content: [
{
type: 'wrapper',
- content: content.flatMap((item) => {
- // Filter out null or undefined items
- if (!item) return []
-
- // If the item is an array (from functions without wrapper in advanced mode)
- if (Array.isArray(item)) {
- return item
- }
-
- // If the item is a wrapper, extract its content
- if (item.type === 'wrapper' && item.content) {
- return item.content
- }
-
- // Return the item if it has a type
- return item.type ? [item] : []
- }),
+ content: flatContent,
},
],
}
}
- return { type: 'doc', content: _.isArray(result) ? result : [result] }
+ // In simple mode, wrap inline content in a wrapper
+ // The result can be a wrapper, an array of wrappers, or inline content
+ if (Array.isArray(result)) {
+ // Array of wrappers (e.g., from concat with newlines)
+ return { type: 'doc', content: result }
+ } else if (result?.type === 'wrapper') {
+ // Already a wrapper
+ return { type: 'doc', content: [result] }
+ } else {
+ // Inline content (text, nodes, etc.) - wrap it
+ return {
+ type: 'doc',
+ content: [{ type: 'wrapper', content: [result] }],
+ }
+ }
}
visitStringLiteral(ctx) {
@@ -113,8 +139,34 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor {
}
visitBrackets(ctx) {
- // TODO
- return ctx.expr().accept(this)
+ const innerContent = ctx.expr().accept(this)
+
+ // In advanced mode, wrap the content with group parenthesis nodes
+ if (this.mode === 'advanced') {
+ const content = []
+
+ // Add opening group parenthesis
+ content.push({ type: 'text', text: '\u200B' })
+ content.push({ type: 'group-opening-paren' })
+ content.push({ type: 'text', text: '\u200B' })
+
+ // Add the inner content
+ if (Array.isArray(innerContent)) {
+ content.push(...innerContent)
+ } else {
+ content.push(innerContent)
+ }
+
+ // Add closing group parenthesis
+ content.push({ type: 'text', text: '\u200B' })
+ content.push({ type: 'group-closing-paren' })
+ content.push({ type: 'text', text: '\u200B' })
+
+ return content
+ }
+
+ // In simple mode, just return the inner content without parentheses
+ return innerContent
}
processString(ctx) {
@@ -137,197 +189,221 @@ export class ToTipTapVisitor extends BaserowFormulaVisitor {
return this.doFunc(functionArgumentExpressions, functionName)
}
- doFunc(functionArgumentExpressions, functionName) {
- const args = Array.from(functionArgumentExpressions, (expr) =>
- expr.accept(this)
- )
+ /**
+ * Helper to process text arguments - adds quotes if needed in simple mode
+ */
+ processTextArg(arg) {
+ if (arg.type === 'text' && typeof arg.text === 'string') {
+ const isBoolean = arg.text === 'true' || arg.text === 'false'
+ const isNumber = !isNaN(Number(arg.text))
+ const hasQuotes =
+ arg.text.length >= 2 &&
+ ((arg.text.startsWith('"') && arg.text.endsWith('"')) ||
+ (arg.text.startsWith("'") && arg.text.endsWith("'")))
+
+ if (isBoolean || isNumber || hasQuotes) {
+ return arg
+ } else {
+ // In simple mode, add quotes
+ return { type: 'text', text: `"${arg.text}"` }
+ }
+ }
+ return arg
+ }
- // 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
+ /**
+ * Adds an argument to content array, spreading if it's an array
+ */
+ addArgToContent(content, arg) {
+ if (Array.isArray(arg)) {
+ content.push(...arg)
+ } else if (arg) {
+ content.push(arg)
+ }
+ }
- const formulaFunctionType = this.functions.get(functionName)
- const node = formulaFunctionType.toNode(processedArgs, this.mode)
+ /**
+ * Builds content for binary operators (arg1 operator arg2)
+ */
+ buildOperatorContent(leftArg, rightArg, operatorSymbol) {
+ const content = []
- // If the function returns an array (like concat with newlines in simple mode),
- // return it directly
- if (Array.isArray(node)) {
- return node
+ // Add left argument
+ const processedLeftArg = this.processTextArg(leftArg)
+ this.addArgToContent(content, processedLeftArg)
+
+ // Add operator
+ if (this.mode === 'advanced') {
+ content.push({
+ type: 'operator-formula-component',
+ attrs: { operatorSymbol },
+ })
+ // Add space after minus operator to distinguish from negative numbers
+ if (operatorSymbol === '-') {
+ content.push({ type: 'text', text: ' ' })
+ }
+ } else {
+ content.push({ type: 'text', text: operatorSymbol })
}
- // If the function doesn't have a proper TipTap component (node is null or type is null),
- // wrap it as text but preserve the arguments
- if (!node || !node.type) {
- const content = []
+ // Add right argument
+ const processedRightArg = this.processTextArg(rightArg)
+ this.addArgToContent(content, processedRightArg)
- // Check if this is an operator and should use symbol instead of function name
- const isOperator = formulaFunctionType.getOperatorSymbol
-
- if (isOperator && args.length === 2) {
- // For binary operators, display as: arg1 symbol arg2
- const [leftArg, rightArg] = args
-
- // Add left argument
- if (leftArg.type === 'text' && typeof leftArg.text === 'string') {
- const isBoolean = leftArg.text === 'true' || leftArg.text === 'false'
- const isNumber = !isNaN(Number(leftArg.text))
- if (isBoolean || isNumber) {
- content.push(leftArg)
- } else {
- content.push({ type: 'text', text: `"${leftArg.text}"` })
- }
- } else if (Array.isArray(leftArg)) {
- // If arg is an array (from nested function calls in advanced mode),
- // spread its elements
- content.push(...leftArg)
- } else if (leftArg) {
- content.push(leftArg)
- }
+ return content
+ }
- // Add space before operator
- content.push({
- type: 'text',
- text: ' ',
- })
+ /**
+ * Builds content for functions in advanced mode
+ */
+ buildFunctionContentAdvanced(functionName, args) {
+ const result = [
+ { type: 'text', text: '\u200B' },
+ {
+ type: 'function-formula-component',
+ attrs: {
+ functionName,
+ hasNoArgs: args.length === 0,
+ },
+ },
+ ]
+
+ // Add arguments
+ args.forEach((arg, index) => {
+ if (index > 0) {
+ result.push({ type: 'function-argument-comma' })
+ }
+ this.addArgToContent(result, arg)
+ })
- // 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 closing parenthesis
+ result.push({
+ type: 'function-closing-paren',
+ attrs: { noArgs: args.length === 0 },
+ })
- // Add space after operator
- content.push({
- type: 'text',
- text: ' ',
- })
-
- // Add right argument
- if (rightArg.type === 'text' && typeof rightArg.text === 'string') {
- const isBoolean =
- rightArg.text === 'true' || rightArg.text === 'false'
- const isNumber = !isNaN(Number(rightArg.text))
- if (isBoolean || isNumber) {
- content.push(rightArg)
- } else {
- content.push({ type: 'text', text: `"${rightArg.text}"` })
- }
- } else if (Array.isArray(rightArg)) {
- // If arg is an array (from nested function calls in advanced mode),
- // spread its elements
- content.push(...rightArg)
- } 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,
- },
- }
+ result.push({ type: 'text', text: '\u200B' })
- // Build the content array with function node + arguments + closing parenthesis
- const result = [functionNode]
+ return result
+ }
- // Add arguments as plain text nodes
- args.forEach((arg, index) => {
- if (index > 0) {
- // Add atomic comma node
- result.push({ type: 'function-argument-comma' })
- }
+ /**
+ * Builds content for functions in simple mode
+ */
+ buildFunctionContentSimple(functionName, args) {
+ const content = [{ type: 'text', text: `${functionName}(` }]
- // 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)
- }
- })
+ args.forEach((arg, index) => {
+ if (index > 0) {
+ content.push({ type: 'text', text: ', ' })
+ }
- // Add closing parenthesis as atomic node
- result.push({ type: 'function-closing-paren' })
+ const processedArg = this.processTextArg(arg)
+ this.addArgToContent(content, processedArg)
+ })
- return result
- } else {
- // For functions, display as: functionName(arg1, arg2, ...)
- content.push({ type: 'text', text: `${functionName}(` })
+ content.push({ type: 'text', text: ')' })
+ return content
+ }
- args.forEach((arg, index) => {
- if (index > 0) {
- content.push({ type: 'text', text: ', ' })
- }
+ doFunc(functionArgumentExpressions, functionName) {
+ const args = Array.from(functionArgumentExpressions, (expr) =>
+ expr.accept(this)
+ )
- // Check if the argument is a complex node or a simple value
- if (arg.type === 'text' && typeof arg.text === 'string') {
- // Don't add quotes for boolean or numeric values
- const isBoolean = arg.text === 'true' || arg.text === 'false'
- const isNumber = !isNaN(Number(arg.text))
-
- if (isBoolean || isNumber) {
- content.push(arg)
- } else {
- // For actual string literals, add quotes
- content.push({ type: 'text', text: `"${arg.text}"` })
- }
- } else if (Array.isArray(arg)) {
- // If arg is an array (from nested function calls in advanced mode),
- // spread its elements
- content.push(...arg)
- } else if (arg) {
- content.push(arg)
- }
- })
+ // Preprocess arguments (special handling for 'get' in advanced mode)
+ const processedArgs = this.preprocessGetArgs(args, functionName)
- content.push({ type: 'text', text: ')' })
- }
+ // Get the node from the runtime function
+ const formulaFunctionType = this.functions.get(functionName)
+ const node = formulaFunctionType.toNode(processedArgs, this.mode)
- // In advanced mode, return inline content without wrapper
- if (this.mode === 'advanced') {
- return content
- }
+ // Early return: if it's a component that needs ZWS wrapping
+ if (
+ node?.type === 'get-formula-component' ||
+ node?.type === 'function-formula-component'
+ ) {
+ return [
+ { type: 'text', text: '\u200B' },
+ node,
+ { type: 'text', text: '\u200B' },
+ ]
+ }
- return {
- type: 'wrapper',
- content,
- }
+ // Early return: if it's already an array (e.g., concat with newlines)
+ if (Array.isArray(node)) {
+ return node
+ }
+
+ // Flatten nested arrays in wrapper content
+ if (node?.type === 'wrapper' && node.content) {
+ node.content = node.content.flat()
+ return node
+ }
+
+ // Early return: if node is valid, use it
+ if (node?.type) {
+ return node
+ }
+
+ // Fallback: build content manually when no proper TipTap component exists
+ return this.buildFallbackContent(args, functionName, formulaFunctionType)
+ }
+
+ /**
+ * Preprocesses arguments for 'get' function in advanced mode
+ */
+ preprocessGetArgs(args, functionName) {
+ if (functionName === 'get' && this.mode === 'advanced') {
+ return 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
+ })
+ }
+ return args
+ }
+
+ /**
+ * Builds fallback content when function doesn't have a TipTap component
+ */
+ buildFallbackContent(args, functionName, formulaFunctionType) {
+ const isOperator = formulaFunctionType.getOperatorSymbol
+
+ // Handle binary operators
+ if (isOperator && args.length === 2) {
+ const [leftArg, rightArg] = args
+ const content = this.buildOperatorContent(
+ leftArg,
+ rightArg,
+ formulaFunctionType.getOperatorSymbol
+ )
+
+ return this.mode === 'advanced' ? content : { type: 'wrapper', content }
}
- return node
+ // Handle functions
+ const content =
+ this.mode === 'advanced'
+ ? this.buildFunctionContentAdvanced(functionName, args)
+ : this.buildFunctionContentSimple(functionName, args)
+
+ return this.mode === 'advanced' ? content : { type: 'wrapper', content }
}
visitBinaryOp(ctx) {
// TODO
+
let op
if (ctx.PLUS()) {
diff --git a/web-frontend/modules/integrations/localBaserow/components/services/FieldMappingForm.vue b/web-frontend/modules/integrations/localBaserow/components/services/FieldMappingForm.vue
index eb4b9489ed..02ba39c84d 100644
--- a/web-frontend/modules/integrations/localBaserow/components/services/FieldMappingForm.vue
+++ b/web-frontend/modules/integrations/localBaserow/components/services/FieldMappingForm.vue
@@ -7,7 +7,7 @@
v-model="fieldValue"
:disabled="!mapping.enabled"
:placeholder="
- $t('upsertRowWorkflowActionForm.fieldMappingPlaceholder')
+ $t('localBaserowUpsertRowServiceForm.fieldMappingPlaceholder')
"
/>
diff --git a/web-frontend/stories/FormulaInputField.stories.mdx b/web-frontend/stories/FormulaInputField.stories.mdx
index 7258c1a23e..583079cce2 100644
--- a/web-frontend/stories/FormulaInputField.stories.mdx
+++ b/web-frontend/stories/FormulaInputField.stories.mdx
@@ -539,7 +539,6 @@ export const Template = (args, { argTypes }) => ({
@input="onInput"
@update:mode="onModeChanged"
/>
-
Emitted Formula: