From 09d9757c64616f6e3d751d11647869ec8a2bb209 Mon Sep 17 00:00:00 2001 From: Jonathan Adeline Date: Tue, 25 Nov 2025 18:26:38 +0400 Subject: [PATCH] Improve formula editor --- .../refactor/improve_formula_editor.json | 9 + tests/cases/tip_tap_visitor_cases.json | 31 +- .../scss/components/formula_input_field.scss | 79 +++ .../function_formula_component.scss | 4 - .../operator_formula_component.scss | 1 + .../formula/FormulaExtensionHelpers.js | 412 ---------------- .../components/formula/FormulaInputField.vue | 99 +++- .../formula/FunctionFormulaComponent.vue | 9 +- .../extensions/ArrowKeyNavigationExtension.js | 225 +++++++++ .../ContextManagementExtension.js | 36 -- .../FormulaClipboardHandler.js | 0 .../FormulaNodes.js} | 170 +++++-- .../FunctionDetectionExtension.js | 70 ++- .../FunctionHelpTooltipExtension.js | 25 +- .../extensions/GroupDetectionExtension.js | 147 ++++++ .../NodeSelectionExtension.js | 0 .../OperatorDetectionExtension.js | 70 ++- .../extensions/SmartDeletionExtension.js | 144 ++++++ .../extensions/ZWSManagementExtension.js | 107 +++++ web-frontend/modules/core/formula/index.js | 5 + .../core/formula/tiptap/fromTipTapVisitor.js | 47 +- .../core/formula/tiptap/toTipTapVisitor.js | 448 ++++++++++-------- .../components/services/FieldMappingForm.vue | 2 +- .../stories/FormulaInputField.stories.mdx | 1 - 24 files changed, 1396 insertions(+), 745 deletions(-) create mode 100644 changelog/entries/unreleased/refactor/improve_formula_editor.json delete mode 100644 web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js create mode 100644 web-frontend/modules/core/components/formula/extensions/ArrowKeyNavigationExtension.js rename web-frontend/modules/core/components/formula/{ => extensions}/ContextManagementExtension.js (89%) rename web-frontend/modules/core/components/formula/{ => extensions}/FormulaClipboardHandler.js (100%) rename web-frontend/modules/core/components/formula/{FormulaInsertionExtension.js => extensions/FormulaNodes.js} (54%) rename web-frontend/modules/core/components/formula/{ => extensions}/FunctionDetectionExtension.js (79%) rename web-frontend/modules/core/components/formula/{ => extensions}/FunctionHelpTooltipExtension.js (81%) create mode 100644 web-frontend/modules/core/components/formula/extensions/GroupDetectionExtension.js rename web-frontend/modules/core/components/formula/{ => extensions}/NodeSelectionExtension.js (100%) rename web-frontend/modules/core/components/formula/{ => extensions}/OperatorDetectionExtension.js (65%) create mode 100644 web-frontend/modules/core/components/formula/extensions/SmartDeletionExtension.js create mode 100644 web-frontend/modules/core/components/formula/extensions/ZWSManagementExtension.js 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') " />