From 8d25a43324e2596662ee93ec98ec8149f109d68d Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 11 Nov 2025 09:47:46 +0100 Subject: [PATCH 1/3] Add new formula editor --- .../components/field/FieldAISubForm.vue | 56 +- web-frontend/locales/en.json | 19 +- .../AutomationBuilderFormulaInput.vue | 65 +- .../ApplicationBuilderFormulaInput.vue | 225 ++++--- .../core/assets/scss/components/all.scss | 6 +- .../data_explorer/data_explorer.scss | 10 - .../data_explorer/data_explorer_node.scss | 64 -- .../components/formula_input_context.scss | 68 ++ .../scss/components/formula_input_field.scss | 95 ++- .../components/get_formula_component.scss | 18 +- .../node_explorer/node_explorer.scss | 42 ++ .../node_explorer/node_explorer_content.scss | 67 ++ .../scss/components/node_help_tooltip.scss | 45 ++ .../core/assets/scss/components/tabs.scss | 21 +- web-frontend/modules/core/components/Tabs.vue | 31 +- .../components/dataExplorer/DataExplorer.vue | 203 ------ .../formula/ContextManagementExtension.js | 282 ++++++++ .../formula/FormulaExtensionHelpers.js | 412 ++++++++++++ .../formula/FormulaInputContext.vue | 236 +++++++ .../components/formula/FormulaInputField.vue | 565 ++++++++-------- .../formula/FormulaInsertionExtension.js | 90 +++ .../formula/FunctionAutoCompleteExtension.js | 100 +++ .../formula/FunctionDeletionExtension.js | 210 ++++++ .../formula/FunctionHelpTooltipExtension.js | 97 +++ .../formula/FunctionHighlightExtension.js | 388 +++++++++++ .../formula/GetFormulaComponent.vue | 103 +-- .../formula/NodeSelectionExtension.js | 112 ++++ .../components/nodeExplorer/NodeExplorer.vue | 256 ++++++++ .../NodeExplorerContent.vue} | 188 +++--- .../nodeExplorer/NodeExplorerTab.vue | 95 +++ .../nodeExplorer/NodeHelpTooltip.vue | 81 +++ .../core/components/settings/McpEndpoint.vue | 2 +- web-frontend/modules/core/formula/index.js | 400 ++++++++++++ .../core/formula/tiptap/fromTipTapVisitor.js | 104 ++- .../core/formula/tiptap/toTipTapVisitor.js | 154 ++++- web-frontend/modules/core/locales/en.json | 11 + .../modules/core/runtimeFormulaTypes.js | 14 +- .../modules/core/utils/dataProviders.js | 50 ++ .../components/row/RowEditModalSidebar.vue | 3 +- .../components/table/ImportFileModal.vue | 2 +- .../stories/FormulaInputField.stories.mdx | 618 ++++++++++++++++++ web-frontend/stories/Tabs.stories.mdx | 23 +- 42 files changed, 4800 insertions(+), 831 deletions(-) delete mode 100644 web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss delete mode 100644 web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss create mode 100644 web-frontend/modules/core/assets/scss/components/formula_input_context.scss create mode 100644 web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss create mode 100644 web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss create mode 100644 web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss delete mode 100644 web-frontend/modules/core/components/dataExplorer/DataExplorer.vue create mode 100644 web-frontend/modules/core/components/formula/ContextManagementExtension.js create mode 100644 web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js create mode 100644 web-frontend/modules/core/components/formula/FormulaInputContext.vue create mode 100644 web-frontend/modules/core/components/formula/FormulaInsertionExtension.js create mode 100644 web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js create mode 100644 web-frontend/modules/core/components/formula/FunctionDeletionExtension.js create mode 100644 web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js create mode 100644 web-frontend/modules/core/components/formula/FunctionHighlightExtension.js create mode 100644 web-frontend/modules/core/components/formula/NodeSelectionExtension.js create mode 100644 web-frontend/modules/core/components/nodeExplorer/NodeExplorer.vue rename web-frontend/modules/core/components/{dataExplorer/DataExplorerNode.vue => nodeExplorer/NodeExplorerContent.vue} (59%) create mode 100644 web-frontend/modules/core/components/nodeExplorer/NodeExplorerTab.vue create mode 100644 web-frontend/modules/core/components/nodeExplorer/NodeHelpTooltip.vue create mode 100644 web-frontend/modules/core/utils/dataProviders.js create mode 100644 web-frontend/stories/FormulaInputField.stories.mdx diff --git a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue index b2c43216ff..aa27a26869 100644 --- a/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue +++ b/premium/web-frontend/modules/baserow_premium/components/field/FieldAISubForm.vue @@ -69,11 +69,12 @@
+ @update:mode="updateMode" + />
@@ -99,6 +100,8 @@ import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm' import FormulaInputField from '@baserow/modules/core/components/formula/FormulaInputField' import SelectAIModelForm from '@baserow/modules/core/components/ai/SelectAIModelForm' import { TextAIFieldOutputType } from '@baserow_premium/aiFieldOutputTypes' +import { buildFormulaFunctionNodes } from '@baserow/modules/core/formula' +import { getDataNodesFromDataProvider } from '@baserow/modules/core/utils/dataProviders' export default { name: 'FieldAISubForm', @@ -111,11 +114,12 @@ export default { return { allowedValues: ['ai_prompt', 'ai_file_field_id', 'ai_output_type'], values: { - ai_prompt: { formula: '' }, + ai_prompt: { formula: '', mode: 'simple' }, ai_output_type: TextAIFieldOutputType.getType(), ai_file_field_id: null, }, fileFieldSupported: false, + localMode: 'simple', } }, computed: { @@ -146,6 +150,29 @@ export default { dataProviders() { return [this.$registry.get('databaseDataProvider', 'fields')] }, + nodesHierarchy() { + const hierarchy = [] + + const filteredDataNodes = getDataNodesFromDataProvider( + this.dataProviders, + this.applicationContext + ) + + if (filteredDataNodes.length > 0) { + hierarchy.push({ + name: this.$t('runtimeFormulaTypes.formulaTypeData'), + type: 'data', + icon: 'iconoir-database', + nodes: filteredDataNodes, + }) + } + + // Add functions and operators from the registry + const formulaNodes = buildFormulaFunctionNodes(this) + hierarchy.push(...formulaNodes) + + return hierarchy + }, isDeactivated() { return this.$registry .get('field', this.fieldType) @@ -171,6 +198,16 @@ export default { ) }, }, + watch: { + 'values.ai_prompt.mode': { + handler(newMode) { + if (newMode && newMode !== this.localMode) { + this.localMode = newMode + } + }, + immediate: true, + }, + }, methods: { /** * When `FormulaInputField` emits a new formula string, we need to emit the @@ -181,6 +218,17 @@ export default { this.v$.values.ai_prompt.formula.$model = newFormulaStr this.$emit('input', { formula: newFormulaStr }) }, + /** + * When the mode changes, update the local mode value + * @param {String} newMode The new mode value + */ + updateMode(newMode) { + this.localMode = newMode + this.values.ai_prompt = { + ...this.values.ai_prompt, + mode: newMode, + } + }, setFileFieldSupported(generativeAIType) { if (generativeAIType) { const modelType = this.$registry.get( diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 31f5586079..6c17e02c1f 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -675,12 +675,25 @@ "ifDescription": "If the first argument is true, returns the second argument, otherwise returns the third argument.", "andDescription": "Returns true if all arguments are true, otherwise returns false.", "orDescription": "Returns true if any argument is true, otherwise returns false.", - "formulaTypeFormula": "Formula", - "formulaTypeOperator": "Operator", + "formulaTypeFormula": "Function | Functions", + "formulaTypeOperator": "Operator | Operators", + "formulaTypeData": "Data", + "formulaTypeDataEmpty": "No data sources available", "categoryText": "Text", "categoryNumber": "Number", "categoryBoolean": "Boolean", "categoryDate": "Date", - "caregoryCondition": "Condition" + "categoryCondition": "Condition" + }, + "formulaInputContext": { + "useRegularInput": "Use regular input", + "useRegularInputModalTitle": "Switch to regular input?", + "useAdvancedInputModalTitle": "Switch to advanced input?", + "useAdvancedInput": "Use advanced input", + "modalMessage": "Switching to a different input mode will clear the current formula. Are you sure you want to continue?" + }, + "nodeExplorer": { + "noResults": "No results found", + "resetSearch": "Reset search" } } diff --git a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue index 57dac950d4..6d61b2ac9f 100644 --- a/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue +++ b/web-frontend/modules/automation/components/AutomationBuilderFormulaInput.vue @@ -3,19 +3,25 @@ v-bind="$attrs" required :value="formulaStr" - :data-providers="dataProviders" - :application-context="applicationContext" - enable-advanced-mode - :mode="currentMode" + :nodes-hierarchy="nodesHierarchy" + context-position="left" + :mode="localMode" + @update:mode="updateMode" @input="updatedFormulaStr" - @mode-changed="updateMode" /> diff --git a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue index a46ce174d2..0c0a92cebe 100644 --- a/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue +++ b/web-frontend/modules/builder/components/ApplicationBuilderFormulaInput.vue @@ -4,102 +4,149 @@ required enable-advanced-mode :value="formulaStr" - :mode="formulaMode" - :data-explorer-loading="dataExplorerLoading" - :data-providers="dataProviders" - :application-context="applicationContext" + :mode="localMode" + :loading="dataExplorerLoading" + :nodes-hierarchy="nodesHierarchy" + :context-position="isInSidePanel ? 'left' : 'bottom'" @input="updatedFormulaStr" - @mode-changed="updateMode" + @update:mode="updateMode" /> - diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index e0e247187d..557d0e2aef 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -167,11 +167,12 @@ @import 'color_picker_context'; @import 'color_input_group'; @import 'formula_input_field'; +@import 'node_help_tooltip'; @import 'get_formula_component'; @import 'color_input'; @import 'group_bys'; -@import 'data_explorer/data_explorer'; -@import 'data_explorer/data_explorer_node'; +@import 'node_explorer/node_explorer'; +@import 'node_explorer/node_explorer_content'; @import 'anchor'; @import 'call_to_action'; @import 'toast_button'; @@ -201,3 +202,4 @@ @import 'code_editor'; @import 'field_constraints'; @import 'workspace_search'; +@import 'formula_input_context'; diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss deleted file mode 100644 index 2931fb57b4..0000000000 --- a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer.scss +++ /dev/null @@ -1,10 +0,0 @@ -.data-explorer { - width: 323px; - - .context__description { - padding: 32px; - text-align: center; - white-space: initial; - line-height: 20px; - } -} diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss deleted file mode 100644 index 9ad48d309b..0000000000 --- a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss +++ /dev/null @@ -1,64 +0,0 @@ -.data-explorer-node__content-icon { - color: $color-neutral-600; -} - -.data-explorer-node__children { - margin-left: 5px; -} - -.data-explorer-node__content { - @extend %ellipsis; - - display: flex; - justify-content: flex-start; - align-items: center; - gap: 6px; - padding: 0 5px; - margin: 0 5px; - font-size: 13px; - border-radius: 3px; - line-height: 24px; - - .data-explorer-node--selected & { - background-color: $color-primary-100; - - .data-explorer-node__content-selected-icon { - color: $color-success-500; - } - } - - .data-explorer-node--level-0 > & { - font-size: 12px; - color: $color-neutral-500; - margin-left: 10px; - - &:hover { - background-color: initial; - } - } - - .data-explorer-node--level-0 .data-explorer-node--level-1 & { - cursor: pointer; - - &:hover { - background-color: $color-neutral-100; - } - } -} - -.data-explorer-node--level-0 { - margin-bottom: 8px; -} - -.data-explorer-node__content-name { - flex: 1; -} - -.data-explorer-node__array-node-more { - border: none; - background: none; - margin-left: 10px; - padding-top: 3px; - color: $color-neutral-600; - cursor: pointer; -} diff --git a/web-frontend/modules/core/assets/scss/components/formula_input_context.scss b/web-frontend/modules/core/assets/scss/components/formula_input_context.scss new file mode 100644 index 0000000000..4dd058fe2b --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/formula_input_context.scss @@ -0,0 +1,68 @@ +.formula-input-context { + width: 400px; + display: flex; + flex-direction: column; + max-height: inherit; + + .node-explorer { + flex: 1; + min-height: 0; + } + + &__tab-content { + padding: 12px; + } + + &__section { + &:not(:last-child) { + margin-bottom: 20px; + } + } + + &__section-title { + font-size: 11px; + color: $palette-neutral-900; + font-weight: 500; + margin-bottom: 8px; + text-transform: capitalize; + } + + &__items { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + } + + &__item { + display: flex; + align-items: center; + padding: 8px 12px 8px 7px; + border-radius: 4px; + cursor: pointer; + gap: 8px; + + &:hover { + background-color: $palette-neutral-100; + } + } + + &__item-icon { + font-size: 14px; + color: $palette-neutral-900; + flex-shrink: 0; + } +} + +.formula-input-context__footer { + flex-shrink: 0; + padding: 12px 16px; + border-top: 1px solid $palette-neutral-200; + height: 36px; + box-sizing: border-box; + display: flex; + align-items: center; + background-color: $white; + + @include rounded($rounded); +} 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 794e80ec33..49d58cba89 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,14 +1,65 @@ +.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: 38px; - padding: 5px 12px; - line-height: 25px; + min-height: 36px; + padding: 5px 12px 1px; // If the field is empty, then give it the // same padding as a normal form input field. &:has(div.is-editor-empty) { - padding: 12px 16px; + padding: 10px 16px; line-height: 100%; } @@ -17,10 +68,32 @@ 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: $color-primary-500; + border-color: $palette-blue-500; + + &.formula-input-field--error { + border-color: $palette-red-400; + } +} + +.formula-input-field--error { + border-color: $palette-red-400; } .formula-input-field--disabled { @@ -35,17 +108,7 @@ .ProseMirror div.is-editor-empty:first-child::before { content: attr(data-placeholder); float: left; - color: $color-neutral-500; + color: $palette-neutral-500; pointer-events: none; height: 0; } - -.formula-input-field__advanced-input { - width: 100%; - padding: 12px 16px; - line-height: 100%; - - &::placeholder { - color: $color-neutral-500; - } -} 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 cc463cfe93..b67e72b26c 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,39 +1,41 @@ .get-formula-component { cursor: pointer; display: inline-block; - padding: 2px 8px; - background-color: $color-neutral-100; + vertical-align: middle; + background-color: $palette-neutral-100; font-size: 12px; - margin: 1px 0; border-radius: 3px; user-select: none; + min-height: 24px; line-height: 18px; + box-sizing: border-box; + padding: 2px 8px; @include rounded($rounded); } .get-formula-component--error { - background-color: $color-error-100; + background-color: $palette-red-100; } .get-formula-component--selected { - background-color: $color-primary-100; + background-color: $palette-blue-50; } .get-formula-component__caret { - color: $color-neutral-400; + color: $palette-neutral-700; padding-left: 3px; padding-right: 3px; font-size: 12px; } .get-formula-component__remove { - color: $color-neutral-500; + color: $palette-neutral-700; padding-left: 4px; font-size: 10px; &:hover { text-decoration: none; - color: $color-neutral-900; + color: $palette-neutral-1100; } } diff --git a/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss new file mode 100644 index 0000000000..6d0182a66b --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer.scss @@ -0,0 +1,42 @@ +.node-explorer { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + + > div { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + } + + .context__description { + text-align: center; + white-space: initial; + line-height: 20px; + } +} + +.node-explorer__content--empty { + padding: 24px; + color: $palette-neutral-900; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; +} + +.node-explorer-tab { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.node-explorer-tab__scrollable { + flex: 1; + min-height: 0; + overflow: hidden auto; +} diff --git a/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss new file mode 100644 index 0000000000..c9253fea95 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/node_explorer/node_explorer_content.scss @@ -0,0 +1,67 @@ +.node-explorer-content__content-icon { + color: $palette-neutral-600; +} + +.node-explorer-content__children { + margin-left: 5px; +} + +.node-explorer-content__name { + flex: 1; +} + +.node-explorer-content__content { + @extend %ellipsis; + + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + padding: 0 5px; + margin: 0 5px; + font-size: 13px; + border-radius: 3px; + line-height: 24px; + + .node-explorer-content--selected > & { + background-color: $palette-blue-50; + + .node-explorer-content__selected-icon { + color: $palette-green-500; + } + } + + .node-explorer-content--level-0 > & { + font-size: 11px; + margin-left: 10px; + + &:hover { + background-color: initial; + } + + .node-explorer-content__name { + color: $palette-neutral-900; + } + } + + .node-explorer-content--level-0 .node-explorer-content--level-1 & { + cursor: pointer; + + &:hover { + background-color: $palette-neutral-100; + } + } +} + +.node-explorer-content--level-0 { + margin-bottom: 8px; +} + +.node-explorer-content__array-node-more { + border: none; + background: none; + margin-left: 10px; + padding-top: 3px; + color: $palette-neutral-600; + cursor: pointer; +} diff --git a/web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss b/web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss new file mode 100644 index 0000000000..c04cc758a4 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/node_help_tooltip.scss @@ -0,0 +1,45 @@ +.node-help-tooltip { + padding: 20px; + max-width: 320px; + background: $palette-neutral-100; + + @include rounded($rounded-md); + + &__header { + display: flex; + align-items: center; + margin-bottom: 12px; + } + + &__icon { + width: 40px; + height: 40px; + background: $white; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + flex-shrink: 0; + border: 1px solid $palette-neutral-400; + + @include rounded($rounded-lg); + @include elevation($elevation-low); + } + + &__icon-symbol { + font-size: 18px; + color: $palette-neutral-700; + } + + &__title { + font-size: 16px; + font-weight: 500; + margin: 0; + } + + &__description { + font-size: 12px; + + @extend %mb-16; + } +} diff --git a/web-frontend/modules/core/assets/scss/components/tabs.scss b/web-frontend/modules/core/assets/scss/components/tabs.scss index af86d5aad5..e96e000e34 100644 --- a/web-frontend/modules/core/assets/scss/components/tabs.scss +++ b/web-frontend/modules/core/assets/scss/components/tabs.scss @@ -1,6 +1,13 @@ .tabs { width: 100%; background-color: $white; + flex-direction: column; + flex: 1; + min-height: 0; + + &.tabs--rounded { + @include rounded($rounded-md); + } } .tabs--full-height { @@ -17,14 +24,14 @@ margin: 0; gap: 24px; font-weight: 500; - border-bottom: solid 1px $palette-neutral-100; + border-bottom: solid 1px $palette-neutral-200; padding-left: 15px; .tabs--large-offset & { padding-left: 40px; } - .tabs--nopadding & { + .tabs--header-nopadding & { padding-left: 0; } } @@ -85,13 +92,21 @@ .tab { padding: 14px 15px; position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; .tabs--full-height & { overflow: auto; height: 100%; } - .tabs--nopadding & { + .tabs--content-no-x-padding & { padding: 14px 0; } + + .tabs--content-no-padding & { + padding: 0; + } } diff --git a/web-frontend/modules/core/components/Tabs.vue b/web-frontend/modules/core/components/Tabs.vue index 545e72890f..a1d28e13a5 100644 --- a/web-frontend/modules/core/components/Tabs.vue +++ b/web-frontend/modules/core/components/Tabs.vue @@ -5,8 +5,11 @@ 'tabs--full-height': fullHeight, 'tabs--large-offset': largeOffset, 'tabs--large': large, - 'tabs--nopadding': noPadding, + 'tabs--header-nopadding': headerNoPadding, + 'tabs--content-no-x-padding': contentNoXPadding, + 'tabs--content-no-padding': contentNoPadding, 'tabs--grow-items': growItems, + 'tabs--rounded': rounded, }" > + @@ -72,7 +76,7 @@ export default { default: null, }, /** - * Whether the tabs container should add some extra space to the left. + * Whether the tabs header container should add some extra space to the left. */ largeOffset: { type: Boolean, @@ -90,7 +94,23 @@ export default { /** * Removes the padding from the tabs container and header. */ - noPadding: { + headerNoPadding: { + type: Boolean, + required: false, + default: false, + }, + /** + * Removes the left and right padding from the tabs container only. + */ + contentNoXPadding: { + type: Boolean, + required: false, + default: false, + }, + /** + * Removes padding (x and y) from the tabs container only. + */ + contentNoPadding: { type: Boolean, required: false, default: false, @@ -108,6 +128,11 @@ export default { required: false, default: null, }, + rounded: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { diff --git a/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue b/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue deleted file mode 100644 index ea27509d31..0000000000 --- a/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue +++ /dev/null @@ -1,203 +0,0 @@ - - - diff --git a/web-frontend/modules/core/components/formula/ContextManagementExtension.js b/web-frontend/modules/core/components/formula/ContextManagementExtension.js new file mode 100644 index 0000000000..fa3050bc9c --- /dev/null +++ b/web-frontend/modules/core/components/formula/ContextManagementExtension.js @@ -0,0 +1,282 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' + +const contextManagementPluginKey = new PluginKey('contextManagement') + +/** + * @name ContextManagementExtension + * @description Manages the visibility and positioning of the formula input's + * context menu (the data explorer and function list). It handles focus and blur + * events to automatically show or hide the context menu. It also provides commands + * to control the menu programmatically and reposition it based on the surrounding UI. + */ +export const ContextManagementExtension = Extension.create({ + name: 'contextManagement', + + addOptions() { + return { + vueComponent: null, + contextPosition: 'bottom', // 'bottom', 'left', 'right' + disabled: false, + readOnly: false, + } + }, + + addStorage() { + return { + ignoreNextBlur: false, + clickOutsideEventCancel: null, + } + }, + + addCommands() { + return { + repositionContext: + () => + ({ editor }) => { + const { vueComponent } = this.options + + if (!vueComponent || !vueComponent.isFocused) { + return false + } + + if (vueComponent && vueComponent.$nextTick) { + vueComponent.$nextTick(() => { + if (!vueComponent.isFocused) return + + // Read directly from Vue component to get reactive value + const contextPosition = + vueComponent?.contextPosition ?? this.options.contextPosition + let config + + switch (contextPosition) { + case 'left': + config = { + vertical: 'top', + horizontal: 'left', + needsDynamicOffset: true, + } + break + case 'bottom': + config = { + vertical: 'bottom', + horizontal: 'left', + verticalOffset: 10, + horizontalOffset: 0, + } + break + case 'right': + config = { + vertical: 'top', + horizontal: 'left', + needsDynamicOffset: true, + } + break + default: + config = { + vertical: 'bottom', + horizontal: 'left', + verticalOffset: 0, + horizontalOffset: -400, + } + } + + const { vertical, horizontal } = config + let { verticalOffset = 0, horizontalOffset = 0 } = config + + // Calculate dynamic offsets if necessary + if (config.needsDynamicOffset) { + const inputRect = vueComponent.$el?.getBoundingClientRect() + const contextRect = + vueComponent.$refs?.formulaInputContext?.$el?.getBoundingClientRect() + + switch (contextPosition) { + case 'left': + verticalOffset = -inputRect?.height || 0 + horizontalOffset = -(contextRect?.width || 0) - 10 + break + case 'right': + verticalOffset = -inputRect?.height || 0 + horizontalOffset = (inputRect?.width || 0) + 10 + break + } + } + + if (vueComponent.$refs?.formulaInputContext) { + vueComponent.$refs.formulaInputContext.show( + vueComponent.$refs.editor.$el, + vertical, + horizontal, + verticalOffset, + horizontalOffset + ) + } + }) + } + + return true + }, + showContext: + () => + ({ editor }) => { + const { vueComponent } = this.options + + // Read directly from Vue component to get reactive values + const disabled = vueComponent?.disabled ?? this.options.disabled + const readOnly = vueComponent?.readOnly ?? this.options.readOnly + + if (!vueComponent || readOnly || disabled) { + return false + } + + vueComponent.isFocused = true + + if (vueComponent && vueComponent.$nextTick) { + vueComponent.$nextTick(() => { + if (!vueComponent.isFocused) return + + editor.commands.unselectNode() + + // Position the context + editor.commands.repositionContext() + + if (vueComponent && vueComponent.$el) { + const { + onClickOutside, + isElement, + } = require('@baserow/modules/core/utils/dom') + + this.storage.clickOutsideEventCancel = onClickOutside( + vueComponent.$el, + (target, event) => { + if ( + vueComponent.$refs?.formulaInputContext && + !isElement( + vueComponent.$refs.formulaInputContext.$el, + target + ) + ) { + editor.commands.hideContext() + } + } + ) + } + }) + } + + return true + }, + hideContext: + () => + ({ editor }) => { + const { vueComponent } = this.options + + if (vueComponent) { + vueComponent.isFocused = false + } + + if (vueComponent?.$refs?.formulaInputContext) { + vueComponent.$refs.formulaInputContext.hide() + } + + editor.commands.unselectNode() + + if (this.storage.clickOutsideEventCancel) { + this.storage.clickOutsideEventCancel() + this.storage.clickOutsideEventCancel = null + } + + return true + }, + + handleDataExplorerMouseDown: () => () => { + this.storage.ignoreNextBlur = true + return true + }, + } + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: contextManagementPluginKey, + props: { + handleDOMEvents: { + focus: (view, event) => { + if (!this.options.disabled && !this.options.readOnly) { + this.editor.commands.showContext() + } + return false + }, + blur: (view, event) => { + if (this.storage.ignoreNextBlur) { + this.storage.ignoreNextBlur = false + return false + } + this.editor.commands.hideContext() + return false + }, + }, + }, + view: () => ({ + update: (view, prevState) => { + // Reposition context when the document changes and context is visible + const { vueComponent } = this.options + if (vueComponent?.isFocused && view.state.doc !== prevState.doc) { + this.editor.commands.repositionContext() + } + }, + }), + }), + ] + }, + + onCreate() { + this.storage.ignoreNextBlur = false + this.storage.clickOutsideEventCancel = null + }, + + onDestroy() { + // Clean up listeners + if (this.storage.clickOutsideEventCancel) { + this.storage.clickOutsideEventCancel() + 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/FormulaExtensionHelpers.js b/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js new file mode 100644 index 0000000000..e6e7337cc3 --- /dev/null +++ b/web-frontend/modules/core/components/formula/FormulaExtensionHelpers.js @@ -0,0 +1,412 @@ +/** + * @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/FormulaInputContext.vue b/web-frontend/modules/core/components/formula/FormulaInputContext.vue new file mode 100644 index 0000000000..436de9fe1b --- /dev/null +++ b/web-frontend/modules/core/components/formula/FormulaInputContext.vue @@ -0,0 +1,236 @@ + + + diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index 020d5bb10d..6a1eccc281 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -1,60 +1,36 @@ @@ -63,29 +39,35 @@ import { Editor, EditorContent, generateHTML, Node } from '@tiptap/vue-2' 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 { FunctionHelpTooltipExtension } from '@baserow/modules/core/components/formula/FunctionHelpTooltipExtension' +import { FormulaInsertionExtension } 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 _ from 'lodash' import parseBaserowFormula from '@baserow/modules/core/formula/parser/parser' import { ToTipTapVisitor } from '@baserow/modules/core/formula/tiptap/toTipTapVisitor' import { RuntimeFunctionCollection } from '@baserow/modules/core/functionCollection' import { FromTipTapVisitor } from '@baserow/modules/core/formula/tiptap/fromTipTapVisitor' import { mergeAttributes } from '@tiptap/core' -import DataExplorer from '@baserow/modules/core/components/dataExplorer/DataExplorer' -import { RuntimeGet } from '@baserow/modules/core/runtimeFormulaTypes' -import { isElement, onClickOutside } from '@baserow/modules/core/utils/dom' -import { isFormulaValid } from '@baserow/modules/core/formula' +import FormulaInputContext from '@baserow/modules/core/components/formula/FormulaInputContext' import { FF_ADVANCED_FORMULA } from '@baserow/modules/core/plugins/featureFlags' +import { isFormulaValid } from '@baserow/modules/core/formula' +import NodeHelpTooltip from '@baserow/modules/core/components/nodeExplorer/NodeHelpTooltip' export default { name: 'FormulaInputField', components: { - DataExplorer, + FormulaInputContext, EditorContent, + NodeHelpTooltip, }, provide() { - // Provide the application context to all formula components return { - applicationContext: this.applicationContext, - dataProviders: this.dataProviders, + nodesHierarchy: this.nodesHierarchy, } }, inject: { @@ -101,33 +83,29 @@ export default { required: false, default: false, }, + readOnly: { + type: Boolean, + required: false, + default: false, + }, placeholder: { type: String, default: null, }, - dataProviders: { - type: Array, - required: false, - default: () => [], - }, - dataExplorerLoading: { + loading: { type: Boolean, required: false, default: false, }, - applicationContext: { - type: Object, - required: true, - }, small: { type: Boolean, required: false, default: false, }, - enableAdvancedMode: { - type: Boolean, + nodesHierarchy: { + type: Array, required: false, - default: false, + default: () => [], }, allowNodeSelection: { type: Boolean, @@ -138,6 +116,17 @@ export default { type: String, required: false, default: 'simple', + validator: (value) => { + return ['advanced', 'simple', 'raw'].includes(value) + }, + }, + contextPosition: { + type: String, + required: false, + default: 'bottom', + validator: (value) => { + return ['bottom', 'left', 'right'].includes(value) + }, }, }, data() { @@ -145,23 +134,22 @@ export default { editor: null, content: null, isFormulaInvalid: false, - dataNodeSelected: null, isFocused: false, - ignoreNextBlur: false, - advancedFormulaValue: this.value, + hoveredFunctionNode: null, + enableAdvancedMode: this.$featureFlagIsEnabled(FF_ADVANCED_FORMULA), + isHandlingModeChange: false, + intersectionObserver: null, } }, computed: { - isAdvancedMode() { - return this.mode === 'advanced' - }, classes() { return { 'form-input--disabled': this.disabled, - 'form-input--error': this.isFormulaInvalid, 'formula-input-field--small': this.small, - 'formula-input-field--focused': !this.disabled && this.isFocused, + 'formula-input-field--focused': + !this.disabled && !this.readOnly && this.isFocused, 'formula-input-field--disabled': this.disabled, + 'formula-input-field--error': this.isFormulaInvalid, } }, placeHolderExt() { @@ -187,27 +175,99 @@ export default { }, }) }, + functionNames() { + const extract = (nodes) => { + let names = [] + if (!nodes) { + return names + } + for (const node of nodes) { + if (node.type === 'function' && node.signature) { + names.push(node.name) + } + const children = node.nodes + if (children) { + names = names.concat(extract(children)) + } + } + + return names + } + + return extract(this.nodesHierarchy) + }, + operators() { + const extract = (nodes) => { + let operators = [] + if (!nodes) { + return operators + } + for (const node of nodes) { + if ( + node.type === 'operator' && + node.signature && + node.signature.operator + ) { + operators.push(node.signature.operator) + } + const children = node.nodes + if (children) { + operators = operators.concat(extract(children)) + } + } + return operators + } + return extract(this.nodesHierarchy) + }, extensions() { const DocumentNode = Document.extend() const TextNode = Text.extend({ inline: true }) - return [ + const extensions = [ DocumentNode, this.wrapperNode, TextNode, this.placeHolderExt, + History.configure({ + depth: 100, + }), + FormulaInsertionExtension.configure({ + vueComponent: this, + }), + NodeSelectionExtension.configure({ + vueComponent: this, + }), + ContextManagementExtension.configure({ + vueComponent: this, + contextPosition: this.contextPosition, + disabled: this.disabled, + readOnly: this.readOnly, + }), + FunctionHelpTooltipExtension.configure({ + vueComponent: this, + }), + FunctionHighlightExtension.configure({ + functionNames: this.mode === 'advanced' ? this.functionNames : [], + operators: this.mode === 'advanced' ? this.operators : [], + }), ...this.formulaComponents, ] - }, - htmlContent() { - if (this.isAdvancedMode) { - return '' + + if (this.mode === 'advanced') { + extensions.push( + FunctionAutoCompleteExtension.configure({ + functionNames: this.functionNames, + }), + FunctionDeletionExtension.configure({ + functionNames: this.functionNames, + }) + ) } + return extensions + }, + htmlContent() { try { - if (!this.content) { - return generateHTML(this.toContent(''), this.extensions) - } return generateHTML(this.content, this.extensions) } catch (e) { console.error('Error while parsing formula content', this.value) @@ -216,82 +276,29 @@ export default { } }, wrapperContent() { - if (this.isAdvancedMode || !this.editor) { - return null - } return this.editor.getJSON() }, - nodes() { - return this.dataProviders - .map((dataProvider) => dataProvider.getNodes(this.applicationContext)) - .filter((dataProviderNodes) => dataProviderNodes.nodes?.length > 0) - }, nodeSelected() { - return this.dataNodeSelected?.attrs?.path || null - }, - showAdvancedCheckbox() { - return ( - this.enableAdvancedMode && - this.$featureFlagIsEnabled(FF_ADVANCED_FORMULA) - ) + return this.editor?.commands.getSelectedNodePath() || null }, }, watch: { disabled(newValue) { - this.editor.setOptions({ editable: !newValue }) + this.editor.setOptions({ editable: !newValue && !this.readOnly }) }, - async isFocused(value) { - if (!value) { - this.$refs.dataExplorer?.hide() - this.unSelectNode() - } else { - // Don't show data explorer in Advanced mode - if (this.isAdvancedMode) { - return - } - - // Wait for the data explorer to appear in the DOM. - await this.$nextTick() - - this.unSelectNode() - - /** - * The Context.vue calculates where to display the Context menu - * relative to the input field that triggered it. When the Context - * decides that the Context menu should be top-adjusted, it will set - * its bottom coordinate to match the input field's top coordinate, - * plus a "margin". This "margin" is the verticalOffset and is a - * negative number; it is negative because the Context menu should not - * appear below the input field. - * - * When the Context menu's bottom coordinate is less than zero, it - * is hidden. - * - * By setting the verticalOffset to the negative value of the input - * field's height, we ensure that as long as the input field is within - * the viewport, the bottom coordinate of the Context menu is always - * >= the bottom coordinate of the input field that triggered it. - */ - const verticalOffset = -Math.abs( - this.$el.getBoundingClientRect().height - ) - - this.$refs.dataExplorer.show( - this.$refs.editor.$el, - 'bottom', - 'left', - verticalOffset, - -330 - ) - } + readOnly(newValue) { + this.editor.setOptions({ editable: !this.disabled && !newValue }) }, - value(value) { - // In advanced mode, just update the value directly - if (this.isAdvancedMode) { - this.advancedFormulaValue = value + + mode(newMode, oldMode) { + // Skip automatic recreation if we're handling it manually in handleModeChange + if (this.isHandlingModeChange) { return } + this.recreateEditor() + }, + value(value) { if (!_.isEqual(value, this.toFormula(this.wrapperContent))) { const content = this.toContent(value) @@ -302,104 +309,108 @@ export default { }, content: { handler() { - if (!_.isEqual(this.content, this.editor.getJSON())) { - this.editor?.commands.setContent(this.htmlContent, false, { + if (this.editor && !_.isEqual(this.content, this.editor.getJSON())) { + this.editor.commands.setContent(this.htmlContent, false, { preserveWhitespace: 'full', + addToHistory: false, }) } }, deep: true, }, - - isAdvancedMode(newValue) { - if (newValue) { - // When switching to advanced mode, preserve current value - this.advancedFormulaValue = this.value - this.isFormulaInvalid = false - } else { - // When switching to simple mode, clear the value to avoid formula parsing errors - this.advancedFormulaValue = '' - this.$emit('input', this.advancedFormulaValue) - } - }, }, mounted() { - if (!this.isAdvancedMode) { - this.content = this.toContent(this.value) - } - - this.editor = new Editor({ - content: this.htmlContent, - editable: !this.disabled, - onUpdate: this.onUpdate, - onFocus: this.onFocus, - onBlur: this.onBlur, - extensions: this.extensions, - parseOptions: { - preserveWhitespace: 'full', - }, - editorProps: { - handleClick: this.unSelectNode, - }, - }) + this.createEditor() + this.setupIntersectionObserver() }, beforeDestroy() { this.editor?.destroy() + this.cleanupIntersectionObserver() }, methods: { - resetField() { - this.isFormulaInvalid = false - this.$emit('input', '') + setupIntersectionObserver() { + this.intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting && this.isFocused) { + this.isFocused = false + if (this.editor) { + this.editor.commands.blur() + } + } + }) + }, + { + root: null, + threshold: 0, + } + ) + + if (this.$refs.formulaInputRoot) { + this.intersectionObserver.observe(this.$refs.formulaInputRoot) + } }, - emitChange() { - if (this.isFormulaInvalid) { - return + cleanupIntersectionObserver() { + if (this.intersectionObserver) { + this.intersectionObserver.disconnect() + this.intersectionObserver = null } + }, + createEditor(formula = null) { + // Use provided formula or fall back to the prop value + this.content = this.toContent(formula || this.value) + this.editor = new Editor({ + content: this.htmlContent, + editable: !this.disabled && !this.readOnly, + onUpdate: this.onUpdate, + extensions: this.extensions, + parseOptions: { + preserveWhitespace: 'full', + }, + editorProps: {}, + }) + }, + 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) - const formulaValue = this.toFormula(this.wrapperContent) - this.$emit('input', formulaValue) + this.editor?.destroy() + this.createEditor(currentFormula) }, - toggleMode() { - this.$emit('mode-changed', this.mode === 'simple' ? 'advanced' : 'simple') + emitChange() { + const functions = new RuntimeFunctionCollection(this.$registry) + const formula = this.toFormula(this.wrapperContent) + this.isFormulaInvalid = !isFormulaValid(formula, functions) + + if (!this.isFormulaInvalid) { + this.$emit('input', this.toFormula(this.wrapperContent)) + } }, onUpdate() { - this.unSelectNode() this.emitChange() }, - onFocus(event) { - // If the input is disabled, we don't want users to be - // able to open the data explorer and select nodes. - if (this.disabled) { - return + handleNodeSelected({ path, node }) { + switch (node.type) { + case 'data': + this.editor.commands.insertDataComponent(path) + break + case 'array': + this.editor.commands.insertDataComponent(path) + break + case 'function': + this.editor.commands.insertFunction(node) + break + case 'operator': + this.editor.commands.insertOperator(node) + break + default: + break } - this.isFocused = true - - this.$el.clickOutsideEventCancel = onClickOutside( - this.$el, - (target, event) => { - if ( - this.$refs.dataExplorer && - // We ignore clicks inside data explorer - !isElement(this.$refs.dataExplorer.$el, target) - ) { - this.isFocused = false - this.editor.commands.blur() - this.$el.clickOutsideEventCancel() - } - } - ) }, onDataExplorerMouseDown() { - // If we click in the data explorer we don't want to close it. - this.ignoreNextBlur = true - }, - onBlur() { - if (this.ignoreNextBlur) { - // Last click was in the data explorer context, we keep the focus. - this.ignoreNextBlur = false - } else { - this.isFocused = false - } + this.editor?.commands.handleDataExplorerMouseDown() }, toContent(formula) { if (!formula) { @@ -409,60 +420,100 @@ 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) - return new ToTipTapVisitor(functionCollection).visit(tree) + return new ToTipTapVisitor(functionCollection, this.mode).visit(tree) } catch (error) { - this.isFormulaInvalid = true return null } }, - toFormula(content) { + toFormula(content, mode = null) { const functionCollection = new RuntimeFunctionCollection(this.$registry) try { - return new FromTipTapVisitor(functionCollection).visit(content) + const formula = new FromTipTapVisitor( + functionCollection, + mode || this.mode + ).visit(content) + + return formula } catch (error) { - this.isFormulaInvalid = true return null } }, - dataComponentClicked(node) { - this.selectNode(node) + dataNodeClicked(node) { + this.editor.commands.selectNode(node) }, - dataExplorerItemSelected({ path }) { - const isInEditingMode = this.dataNodeSelected !== null - if (isInEditingMode) { - this.dataNodeSelected.attrs.path = path - this.emitChange() - } else { - const getNode = new RuntimeGet().toNode([{ text: path }]) - this.editor.commands.insertContent(getNode) + handleEditorClick() { + if (this.editor && !this.disabled && !this.readOnly) { + this.editor.commands.showContext() } - this.editor.commands.focus() }, - selectNode(node) { - if (node) { - this.unSelectNode() - this.dataNodeSelected = node - this.dataNodeSelected.attrs.isSelected = true + handleModeChange(newMode) { + // If switching from advanced to simple, clear the content + if (this.mode === 'advanced' && newMode === 'simple') { + this.isHandlingModeChange = true + this.editor.commands.clearContent() + this.$emit('update:mode', newMode) + this.$emit('input', '') + this.isFormulaInvalid = false + this.isHandlingModeChange = false + } else { + // Otherwise (simple to advanced), keep the current formula + // Get the formula BEFORE changing the mode, using the CURRENT mode + const currentFormula = this.toFormula(this.wrapperContent, this.mode) + + // Set flag to prevent automatic recreation from watcher + this.isHandlingModeChange = true + + // Update the mode + this.$emit('update:mode', newMode) + + // Wait for Vue to update the mode prop + this.$nextTick(() => { + // Recreate the editor with the new mode and preserved formula + this.recreateEditor(currentFormula) + + // Emit the formula value + if (currentFormula) { + this.$emit('input', currentFormula) + } + + // Reset the flag + this.isHandlingModeChange = false + }) } }, - unSelectNode() { - if (this.dataNodeSelected) { - this.dataNodeSelected.attrs.isSelected = false - this.dataNodeSelected = null + undo() { + if (this.editor) { + this.editor.commands.undo() } }, - emitAdvancedChange() { - const functions = new RuntimeFunctionCollection(this.$registry) - if (isFormulaValid(this.advancedFormulaValue, functions)) { - this.isFormulaInvalid = false - this.$emit('input', this.advancedFormulaValue) - } else { - this.isFormulaInvalid = true + redo() { + if (this.editor) { + this.editor.commands.redo() } }, + unSelectNode() { + this.editor?.commands.unselectNode() + }, }, } diff --git a/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js b/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js new file mode 100644 index 0000000000..3580889c0b --- /dev/null +++ b/web-frontend/modules/core/components/formula/FormulaInsertionExtension.js @@ -0,0 +1,90 @@ +import { Node, mergeAttributes, Extension } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-2' +import GetFormulaComponent from '@baserow/modules/core/components/formula/GetFormulaComponent' + +export const GetFormulaComponentNode = Node.create({ + name: 'get-formula-component', + group: 'inline', + inline: true, + draggable: true, + + addAttributes() { + return { + path: { + default: null, + }, + isSelected: { + default: false, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'span[data-formula-component="get-formula-component"]', + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes(HTMLAttributes, { 'data-formula-component': this.name }), + ] + }, + + addNodeView() { + return VueNodeViewRenderer(GetFormulaComponent) + }, +}) + +export const FormulaInsertionExtension = Extension.create({ + name: 'formulaInsertion', + addCommands() { + return { + insertDataComponent: + (path) => + ({ editor, commands }) => { + commands.insertContent({ + type: 'get-formula-component', + attrs: { path }, + }) + + commands.focus() + + return true + }, + insertFunction: + (node) => + ({ editor, commands }) => { + const functionName = node.name + // Insert zero-width space so cursor can be positioned in the text-segment + const functionText = functionName + '(\u200B)' + + const { state } = editor + const startPos = state.selection.from + + commands.insertContent(functionText) + + // Position cursor after the zero-width space (in the text-segment) + const cursorPos = startPos + functionName.length + 2 + + commands.setTextSelection({ from: cursorPos, to: cursorPos }) + + commands.focus() + + return true + }, + insertOperator: + (node) => + ({ editor, commands }) => { + commands.insertContent(node.signature.operator) + + commands.focus() + + return true + }, + } + }, +}) diff --git a/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js b/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js new file mode 100644 index 0000000000..27621e2e97 --- /dev/null +++ b/web-frontend/modules/core/components/formula/FunctionAutoCompleteExtension.js @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000000..afb34974c4 --- /dev/null +++ b/web-frontend/modules/core/components/formula/FunctionDeletionExtension.js @@ -0,0 +1,210 @@ +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/FunctionHelpTooltipExtension.js b/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js new file mode 100644 index 0000000000..dd87a4a532 --- /dev/null +++ b/web-frontend/modules/core/components/formula/FunctionHelpTooltipExtension.js @@ -0,0 +1,97 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from 'prosemirror-state' + +const functionHelpTooltipKey = new PluginKey('functionHelpTooltip') + +export const FunctionHelpTooltipExtension = Extension.create({ + name: 'functionHelpTooltip', + + addOptions() { + return { + vueComponent: null, + selector: '.function-name-highlight', + showDelay: 120, + hideDelay: 60, + } + }, + + addProseMirrorPlugins() { + const { vueComponent, selector, showDelay, hideDelay } = this.options + let lastEl = null + let lastName = null + let showTimer = null + let hideTimer = null + + 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 || []) + } + + const showTooltip = (el, fname) => { + clearTimeout(hideTimer) + clearTimeout(showTimer) + showTimer = setTimeout(() => { + const node = findFunctionNodeByName(fname) + if (!node) return + vueComponent.hoveredFunctionNode = node + vueComponent.$refs.nodeHelpTooltip?.show(el, 'bottom', 'right', 6, 10) + lastEl = el + lastName = fname + }, showDelay) + } + + const hideTooltip = () => { + clearTimeout(showTimer) + clearTimeout(hideTimer) + hideTimer = setTimeout(() => { + vueComponent.$refs.nodeHelpTooltip?.hide() + vueComponent.hoveredFunctionNode = null + lastEl = null + lastName = null + }, hideDelay) + } + + return [ + new Plugin({ + key: functionHelpTooltipKey, + props: { + handleDOMEvents: { + mousemove(view, event) { + const root = view.dom + const el = event.target?.closest?.(selector) + if (el && root.contains(el)) { + const text = (el.textContent || '').trim() + const m = text.match(/^([A-Za-z_][A-Za-z0-9_]*)/) + const fname = m ? m[1] : null + if (!fname) return false + if (lastEl === el && lastName === fname) return false + showTooltip(el, fname) + } else if (lastEl) { + hideTooltip() + } + return false + }, + mouseleave() { + if (lastEl) hideTooltip() + return false + }, + }, + }, + }), + ] + }, +}) diff --git a/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js b/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js new file mode 100644 index 0000000000..4c244e29db --- /dev/null +++ b/web-frontend/modules/core/components/formula/FunctionHighlightExtension.js @@ -0,0 +1,388 @@ +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 4a8e9da2fa..14de66f76d 100644 --- a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue +++ b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue @@ -11,7 +11,7 @@ v-tooltip="$t('getFormulaComponent.errorTooltip')" tooltip-position="top" :hide-tooltip="!isInvalid" - @click="emitToEditor('data-component-clicked', node)" + @click.stop="emitToEditor('data-node-clicked', node)" >