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