From e9091181de89f848dbc5abc53b1db10a3b3796c5 Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Thu, 20 Nov 2025 20:16:37 +0530 Subject: [PATCH 01/12] Update template editor with prosemirror Remove unused dependencies from pnpm-lock.yaml --- common/config/rush/pnpm-lock.yaml | 174 +++++- .../ballerina-side-panel/package.json | 31 +- .../editors/ExpandedEditor/ExpandedEditor.tsx | 12 +- .../ExpandedEditor/controls/LinkDialog.tsx | 175 ++++++ .../controls/RawTemplateMarkdownToolbar.tsx | 404 ++++++++++++++ .../controls/RichTemplateMarkdownToolbar.tsx | 465 ++++++++++++++++ .../controls/TemplateMarkdownToolbar.tsx | 185 ------- .../ExpandedEditor/modes/ExpressionMode.tsx | 4 +- .../ExpandedEditor/modes/TemplateMode.tsx | 120 ++-- .../editors/ExpandedEditor/modes/types.ts | 3 + .../ExpandedEditor/utils/templateUtils.ts | 49 +- .../components/editors/ExpressionEditor.tsx | 71 ++- .../ChipExpressionEditor/CodeUtils.ts | 13 +- .../ChipExpressionEditor/chipStyles.ts | 6 +- .../components/ChipExpressionEditor.tsx | 102 ++-- .../components/HelperPaneToggleButton.tsx | 13 +- .../ChipExpressionEditor/constants.ts | 3 + .../hooks/useHelperPane.ts | 170 ++++++ .../ChipExpressionEditor/types.ts | 18 +- .../ChipExpressionEditor/utils.ts | 85 +++ .../RichTextTemplateEditor.tsx | 513 ++++++++++++++++++ .../RichTextTemplateEditor/chipPlugin.ts | 427 +++++++++++++++ .../markdownCommands.ts | 194 +++++++ .../BI/HelperPaneNew/Views/DocumentConfig.tsx | 6 +- .../src/views/BI/HelperPaneNew/utils/utils.ts | 4 +- .../src/icons/bi-arrow-down.svg | 3 + 26 files changed, 2893 insertions(+), 357 deletions(-) create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/LinkDialog.tsx create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RichTemplateMarkdownToolbar.tsx delete mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/hooks/useHelperPane.ts create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/RichTextTemplateEditor.tsx create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/chipPlugin.ts create mode 100644 workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/markdownCommands.ts create mode 100644 workspaces/common-libs/font-wso2-vscode/src/icons/bi-arrow-down.svg diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 6a74887e8e9..7b06974f807 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -987,11 +987,17 @@ importers: '@codemirror/language': specifier: ~6.11.3 version: 6.11.3 + '@codemirror/language-data': + specifier: ^6.5.2 + version: 6.5.2 + '@codemirror/lint': + specifier: ~6.8.1 + version: 6.8.5 '@codemirror/state': specifier: ~6.5.2 version: 6.5.2 '@codemirror/view': - specifier: ~6.38.6 + specifier: ~6.38.8 version: 6.38.8 '@emotion/react': specifier: ^11.14.0 @@ -1017,6 +1023,42 @@ importers: lodash: specifier: ~4.17.21 version: 4.17.21 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + prosemirror-commands: + specifier: ^1.5.2 + version: 1.7.1 + prosemirror-gapcursor: + specifier: ^1.4.0 + version: 1.4.0 + prosemirror-history: + specifier: ^1.3.2 + version: 1.5.0 + prosemirror-inputrules: + specifier: ^1.4.0 + version: 1.5.1 + prosemirror-keymap: + specifier: ^1.2.2 + version: 1.2.3 + prosemirror-markdown: + specifier: ^1.12.0 + version: 1.13.2 + prosemirror-model: + specifier: ^1.19.4 + version: 1.25.4 + prosemirror-schema-basic: + specifier: ^1.2.2 + version: 1.2.4 + prosemirror-schema-list: + specifier: ^1.3.0 + version: 1.5.1 + prosemirror-state: + specifier: ^1.4.3 + version: 1.4.4 + prosemirror-view: + specifier: ^1.33.0 + version: 1.41.3 react: specifier: 18.2.0 version: 18.2.0 @@ -9926,6 +9968,9 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash.camelcase@4.3.9': resolution: {integrity: sha512-ys9/hGBfsKxzmFI8hckII40V0ASQ83UM2pxfQRghHAwekhH4/jWtjz/3/9YDy7ZpUd/H0k2STSqmPR28dnj7Zg==} @@ -9944,6 +9989,9 @@ packages: '@types/lodash@4.17.17': resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -9953,6 +10001,9 @@ packages: '@types/mdurl@1.0.5': resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -18361,6 +18412,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + original@1.0.2: resolution: {integrity: sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==} @@ -19427,6 +19481,42 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.2: + resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-transform@1.10.5: + resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} + + prosemirror-view@1.41.3: + resolution: {integrity: sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -20412,6 +20502,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -36568,6 +36661,8 @@ snapshots: dependencies: '@types/node': 22.15.35 + '@types/linkify-it@5.0.0': {} + '@types/lodash.camelcase@4.3.9': dependencies: '@types/lodash': 4.17.17 @@ -36586,6 +36681,11 @@ snapshots: '@types/lodash@4.17.17': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 @@ -36596,6 +36696,8 @@ snapshots: '@types/mdurl@1.0.5': {} + '@types/mdurl@2.0.0': {} + '@types/mdx@2.0.13': {} '@types/mime-types@2.1.4': {} @@ -48399,6 +48501,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + orderedmap@2.1.1: {} + original@1.0.2: dependencies: url-parse: 1.5.10 @@ -49530,6 +49634,72 @@ snapshots: property-information@7.1.0: {} + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.3 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.2: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + prosemirror-model: 1.25.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.10.5 + prosemirror-view: 1.41.3 + + prosemirror-transform@1.10.5: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.3: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.10.5 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -51054,6 +51224,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.3 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + router@2.2.0: dependencies: debug: 4.4.3(supports-color@8.1.1) diff --git a/workspaces/ballerina/ballerina-side-panel/package.json b/workspaces/ballerina/ballerina-side-panel/package.json index a0d36fc7c3e..41ab5886a8b 100644 --- a/workspaces/ballerina/ballerina-side-panel/package.json +++ b/workspaces/ballerina/ballerina-side-panel/package.json @@ -18,14 +18,33 @@ "author": "", "license": "ISC", "dependencies": { - "react": "18.2.0", - "react-dom": "18.2.0", + "@codemirror/autocomplete": "~6.19.1", + "@codemirror/commands": "~6.10.0", + "@codemirror/language-data": "^6.5.2", + "@codemirror/lint": "~6.8.1", + "@codemirror/state": "~6.5.2", + "@codemirror/view": "~6.38.8", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@wso2/ui-toolkit": "workspace:*", + "@github/markdown-toolbar-element": "^2.2.3", "@wso2/ballerina-core": "workspace:*", "@wso2/ballerina-rpc-client": "workspace:*", + "@wso2/ui-toolkit": "workspace:*", "lodash": "~4.17.21", + "markdown-it": "^14.1.0", + "prosemirror-commands": "^1.5.2", + "prosemirror-gapcursor": "^1.4.0", + "prosemirror-history": "^1.3.2", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.12.0", + "prosemirror-model": "^1.19.4", + "prosemirror-schema-basic": "^1.2.2", + "prosemirror-schema-list": "^1.3.0", + "prosemirror-state": "^1.4.3", + "prosemirror-view": "^1.33.0", + "react": "18.2.0", + "react-dom": "18.2.0", "react-hook-form": "7.56.4", "react-markdown": "~10.1.0", "rehype-raw": "^7.0.0", @@ -41,10 +60,10 @@ }, "devDependencies": { "@storybook/react": "^6.5.16", + "@types/lodash": "~4.17.16", "@types/react": "18.2.0", "@types/react-dom": "18.2.0", - "typescript": "5.8.3", - "@types/lodash": "~4.17.16", - "copyfiles": "^2.4.1" + "copyfiles": "^2.4.1", + "typescript": "5.8.3" } } diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx index 23d55da3357..631833c5a3b 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx @@ -61,6 +61,7 @@ interface ExpandedPromptEditorProps { // Error diagnostics props error?: FieldError; formDiagnostics?: DiagnosticMessage[]; + inputMode?: InputMode; } const ModalContainer = styled.div` @@ -168,7 +169,8 @@ export const ExpandedEditor: React.FC = ({ extractArgsFromFunction, getHelperPane, error, - formDiagnostics + formDiagnostics, + inputMode }) => { const promptFields = ["query", "instructions", "role"]; @@ -186,6 +188,7 @@ export const ExpandedEditor: React.FC = ({ }, [defaultMode]); useEffect(() => { + // Only text mode and prompt mode don't support preview if (mode === "text") { setShowPreview(false); } @@ -234,7 +237,7 @@ export const ExpandedEditor: React.FC = ({ error, formDiagnostics }), - // Props for template mode + // Props for template mode (uses ProseMirror with source view toggle) ...(mode === "template" && { completions, fileName, @@ -243,10 +246,9 @@ export const ExpandedEditor: React.FC = ({ rawExpression, extractArgsFromFunction, getHelperPane, - isPreviewMode: showPreview, - onTogglePreview: (enabled: boolean) => setShowPreview(enabled), error, - formDiagnostics + formDiagnostics, + inputMode }) }; // HACK: Must find a proper central way to manager popups diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/LinkDialog.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/LinkDialog.tsx new file mode 100644 index 00000000000..ca85542738d --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/LinkDialog.tsx @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect } from "react"; +import styled from "@emotion/styled"; +import { TextField, Button, Codicon, Divider, Typography, ThemeColors } from "@wso2/ui-toolkit"; + +const PopupContainer = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 30000; + display: flex; + justify-content: center; + align-items: center; +`; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 29999; +`; + +const PopupBox = styled.div` + width: 400px; + max-width: 90vw; + max-height: 90vh; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 16px; + border-radius: 3px; + background-color: ${ThemeColors.SURFACE_DIM}; + box-shadow: 0 3px 8px rgb(0 0 0 / 0.2); + z-index: 30001; +`; + +const PopupHeader = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + padding-inline: 16px; + margin-bottom: 8px; +`; + +const PopupContent = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; +`; + +const DialogActions = styled.div` + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + padding: 0 16px 8px; +`; + +interface LinkDialogProps { + isOpen: boolean; + onClose: () => void; + onInsert: (href: string, title?: string) => void; + initialTitle?: string; +} + +export const LinkDialog: React.FC = ({ + isOpen, + onClose, + onInsert, + initialTitle = "" +}) => { + const [url, setUrl] = useState(""); + const [title, setTitle] = useState(initialTitle); + + useEffect(() => { + if (isOpen) { + setUrl(""); + setTitle(initialTitle); + } + }, [isOpen, initialTitle]); + + const handleInsert = () => { + if (!url.trim()) return; + onInsert(url.trim(), title.trim() || undefined); + handleClose(); + }; + + const handleClose = () => { + setUrl(""); + setTitle(""); + onClose(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && url.trim()) { + e.preventDefault(); + handleInsert(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + + if (!isOpen) return null; + + return ( + <> + + + + + + Create a link + + + + + + setUrl((e.target as HTMLInputElement).value)} + autoFocus={true} + required={true} + /> + + setTitle((e.target as HTMLInputElement).value)} + /> + + + + + + + + + ); +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx new file mode 100644 index 00000000000..fa774e304c8 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx @@ -0,0 +1,404 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useRef, useEffect } from "react"; +import styled from "@emotion/styled"; +import { ThemeColors, Icon, Switch } from "@wso2/ui-toolkit"; +import { EditorView } from "@codemirror/view"; +import { + insertMarkdownFormatting, + insertMarkdownHeader, + insertMarkdownLink, + insertMarkdownBlockquote, + insertMarkdownUnorderedList, + insertMarkdownOrderedList, + undoCommand, + redoCommand, + canUndo, + canRedo +} from "../utils/templateUtils"; +import { HelperPaneToggleButton } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton"; + +const ToolbarContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 8px 12px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 4px 4px 0 0; + flex-wrap: wrap; + font-family: GilmerMedium; +`; + +const ToolbarButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +`; + +const ToolbarButton = styled.button<{ isActive?: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background-color: ${(props: { isActive?: boolean }) => props.isActive ? ThemeColors.SECONDARY_CONTAINER : 'transparent'}; + color: ${ThemeColors.ON_SURFACE}; + border: 1px solid ${(props: { isActive?: boolean }) => props.isActive ? ThemeColors.OUTLINE : 'transparent'}; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + border-color: ${ThemeColors.OUTLINE}; + } + + &:active:not(:disabled) { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${ThemeColors.PRIMARY}; + outline-offset: 2px; + } +`; + +const ToolbarDivider = styled.div` + width: 1px; + height: 24px; + background-color: ${ThemeColors.OUTLINE_VARIANT}; + margin: 0 4px; +`; + +const SplitButtonContainer = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +const SplitButtonMain = styled(ToolbarButton)` + border-radius: 4px 0 0 4px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-right: none; + min-width: 40px; + font-size: 12px; + font-weight: 600; +`; + +const SplitButtonDropdown = styled(ToolbarButton)` + width: 24px; + border-radius: 0 4px 4px 0; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +`; + +const DropdownMenu = styled.div<{ isOpen: boolean }>` + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 120px; + background-color: var(--vscode-dropdown-background); + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 4px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + z-index: 1000; + display: ${(props: { isOpen: boolean }) => props.isOpen ? 'block' : 'none'}; + overflow: hidden; +`; + +const DropdownItem = styled.button<{ size: number }>` + width: 100%; + padding: 8px 12px; + background-color: transparent; + color: ${ThemeColors.ON_SURFACE}; + border: none; + text-align: left; + cursor: pointer; + font-size: ${(props: { size: number }) => { + const sizes: { [key: number]: string } = { + 1: '16px', + 2: '15px', + 3: '14px', + 4: '13px', + 5: '12px', + 6: '11px' + }; + return sizes[props.size] || '14px'; + }}; + font-weight: ${(props: { size: number }) => props.size <= 3 ? '600' : '500'}; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:active { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:focus-visible { + outline: 2px solid ${ThemeColors.PRIMARY}; + outline-offset: -2px; + } +`; + +interface RawTemplateMarkdownToolbarProps { + editorView: EditorView | null; + isSourceView?: boolean; + onToggleView?: () => void; + helperPaneToggle?: { + ref: React.RefObject; + isOpen: boolean; + onClick: () => void; + }; +} + +export const RawTemplateMarkdownToolbar = React.forwardRef(({ + editorView, + isSourceView = false, + onToggleView, + helperPaneToggle +}, ref) => { + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + const [currentHeadingLevel, setCurrentHeadingLevel] = useState(1); + const [isHeadingDropdownOpen, setIsHeadingDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + // Update toolbar state when editor state changes + React.useEffect(() => { + if (!editorView) return; + + const updateListener = () => { + forceUpdate(); + }; + + editorView.dom.addEventListener('input', updateListener); + editorView.dom.addEventListener('click', updateListener); + editorView.dom.addEventListener('keyup', updateListener); + + return () => { + editorView.dom.removeEventListener('input', updateListener); + editorView.dom.removeEventListener('click', updateListener); + editorView.dom.removeEventListener('keyup', updateListener); + }; + }, [editorView]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsHeadingDropdownOpen(false); + } + }; + + if (isHeadingDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isHeadingDropdownOpen]); + + // Prevent buttons from taking focus away from the editor + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + const handleBold = () => insertMarkdownFormatting(editorView, '**'); + const handleItalic = () => insertMarkdownFormatting(editorView, '_'); + // const handleCode = () => insertMarkdownFormatting(editorView, '`'); // Inline code formatting disabled for now + const handleLink = () => insertMarkdownLink(editorView); + const handleQuote = () => insertMarkdownBlockquote(editorView); + const handleUnorderedList = () => insertMarkdownUnorderedList(editorView); + const handleOrderedList = () => insertMarkdownOrderedList(editorView); + + const handleHeader = (level?: number) => { + const headingLevel = level ?? currentHeadingLevel; + insertMarkdownHeader(editorView, headingLevel); + if (level !== undefined) { + setCurrentHeadingLevel(level); + setIsHeadingDropdownOpen(false); + } + }; + + const toggleHeadingDropdown = () => setIsHeadingDropdownOpen(!isHeadingDropdownOpen); + + const isUndoAvailable = editorView ? canUndo(editorView) : false; + const isRedoAvailable = editorView ? canRedo(editorView) : false; + + return ( + + + {helperPaneToggle && ( + + )} + + + + undoCommand(editorView)} + onMouseDown={handleMouseDown} + > + + + + redoCommand(editorView)} + onMouseDown={handleMouseDown} + > + + + + + + + + + + + + + + {/* + + */} + + + + + + + + + handleHeader()} + onMouseDown={handleMouseDown} + > + H{currentHeadingLevel} + + + + + + {[1, 2, 3, 4, 5, 6].map((level) => ( + handleHeader(level)} + onMouseDown={handleMouseDown} + > + Heading {level} + + ))} + + + + + + + + + + + + + + + + + + + {onToggleView && ( + + )} + + ); +}); + +RawTemplateMarkdownToolbar.displayName = 'RawTemplateMarkdownToolbar'; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RichTemplateMarkdownToolbar.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RichTemplateMarkdownToolbar.tsx new file mode 100644 index 00000000000..f035bd63e3e --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RichTemplateMarkdownToolbar.tsx @@ -0,0 +1,465 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useRef, useEffect } from "react"; +import styled from "@emotion/styled"; +import { ThemeColors, Icon, Switch } from "@wso2/ui-toolkit"; +import { EditorView } from "prosemirror-view"; +import { + toggleBold, + toggleItalic, + toggleLink, + toggleHeading, + toggleBlockquote, + toggleBulletList, + toggleOrderedList, + isMarkActive, + isNodeActive, + isListActive, + undoCommand, + redoCommand, + canUndo, + canRedo +} from "../../MultiModeExpressionEditor/RichTextTemplateEditor/markdownCommands"; +import { HelperPaneToggleButton } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton"; +import { LinkDialog } from "./LinkDialog"; + +const ToolbarContainer = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 8px 12px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 4px 4px 0 0; + flex-wrap: wrap; + font-family: GilmerMedium; +`; + +const ToolbarButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +`; + +const ToolbarButton = styled.button<{ isActive?: boolean }>` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background-color: ${(props: { isActive?: boolean }) => props.isActive ? ThemeColors.SECONDARY_CONTAINER : 'transparent'}; + color: ${ThemeColors.ON_SURFACE}; + border: 1px solid ${(props: { isActive?: boolean }) => props.isActive ? ThemeColors.OUTLINE : 'transparent'}; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + border-color: ${ThemeColors.OUTLINE}; + } + + &:active:not(:disabled) { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${ThemeColors.PRIMARY}; + outline-offset: 2px; + } +`; + +const ToolbarDivider = styled.div` + width: 1px; + height: 24px; + background-color: ${ThemeColors.OUTLINE_VARIANT}; + margin: 0 4px; +`; + +const SplitButtonContainer = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +const SplitButtonMain = styled(ToolbarButton)` + border-radius: 4px 0 0 4px; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-right: none; + min-width: 40px; + font-size: 12px; + font-weight: 600; +`; + +const SplitButtonDropdown = styled(ToolbarButton)` + width: 24px; + border-radius: 0 4px 4px 0; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; +`; + +const DropdownMenu = styled.div<{ isOpen: boolean }>` + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 120px; + background-color: var(--vscode-dropdown-background); + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 4px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + z-index: 1000; + display: ${(props: { isOpen: boolean }) => props.isOpen ? 'block' : 'none'}; + overflow: hidden; +`; + +const DropdownItem = styled.button<{ size: number }>` + width: 100%; + padding: 8px 12px; + background-color: transparent; + color: ${ThemeColors.ON_SURFACE}; + border: none; + text-align: left; + cursor: pointer; + font-size: ${(props: { size: number }) => { + const sizes: { [key: number]: string } = { + 1: '16px', + 2: '15px', + 3: '14px', + 4: '13px', + 5: '12px', + 6: '11px' + }; + return sizes[props.size] || '14px'; + }}; + font-weight: ${(props: { size: number }) => props.size <= 3 ? '600' : '500'}; + transition: background-color 0.2s ease; + + &:hover { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:active { + background-color: ${ThemeColors.SECONDARY_CONTAINER}; + } + + &:focus-visible { + outline: 2px solid ${ThemeColors.PRIMARY}; + outline-offset: -2px; + } +`; + +interface RichTemplateMarkdownToolbarProps { + editorView: EditorView | null; + isSourceView?: boolean; + onToggleView?: () => void; + helperPaneToggle?: { + ref: React.RefObject; + isOpen: boolean; + onClick: () => void; + }; +} + +export const RichTemplateMarkdownToolbar = React.forwardRef(({ + editorView, + isSourceView = false, + onToggleView, + helperPaneToggle +}, ref) => { + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + const [currentHeadingLevel, setCurrentHeadingLevel] = useState(1); + const [isHeadingDropdownOpen, setIsHeadingDropdownOpen] = useState(false); + const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false); + const [selectedTextForLink, setSelectedTextForLink] = useState(""); + const dropdownRef = useRef(null); + + // Update toolbar state when editor state changes + React.useEffect(() => { + if (!editorView) return; + + const updateListener = () => { + forceUpdate(); + }; + + editorView.dom.addEventListener('input', updateListener); + editorView.dom.addEventListener('click', updateListener); + editorView.dom.addEventListener('keyup', updateListener); + + return () => { + editorView.dom.removeEventListener('input', updateListener); + editorView.dom.removeEventListener('click', updateListener); + editorView.dom.removeEventListener('keyup', updateListener); + }; + }, [editorView]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsHeadingDropdownOpen(false); + } + }; + + if (isHeadingDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isHeadingDropdownOpen]); + + // Prevent buttons from taking focus away from the editor + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + const executeCommand = (command: (state: any, dispatch?: any, view?: any) => boolean) => { + if (!editorView) return; + const didChange = command(editorView.state, editorView.dispatch, editorView); + if (didChange) { + forceUpdate(); + } + editorView.focus(); + }; + + const handleHeader = (level?: number) => { + const headingLevel = level ?? currentHeadingLevel; + executeCommand(toggleHeading(headingLevel)); + if (level !== undefined) { + setCurrentHeadingLevel(level); + setIsHeadingDropdownOpen(false); + } + }; + + const toggleHeadingDropdown = () => setIsHeadingDropdownOpen(!isHeadingDropdownOpen); + + const handleLinkButtonClick = () => { + if (!editorView) return; + + const schema = editorView.state.schema; + const { from, to } = editorView.state.selection; + + // If already has link, remove it immediately + if (from !== to && editorView.state.doc.rangeHasMark(from, to, schema.marks.link)) { + executeCommand(toggleLink()); + return; + } + + // Get selected text if any to pre-fill title field + const selectedText = editorView.state.doc.textBetween(from, to, ' '); + setSelectedTextForLink(selectedText); + setIsLinkDialogOpen(true); + }; + + const handleInsertLink = (href: string, title?: string) => { + executeCommand(toggleLink(href, title)); + setIsLinkDialogOpen(false); + }; + + const schema = editorView?.state.schema; + + const isBoldActive = editorView && schema ? isMarkActive(editorView.state, schema.marks.strong) : false; + const isItalicActive = editorView && schema ? isMarkActive(editorView.state, schema.marks.em) : false; + // const isCodeActive = editorView && schema ? isMarkActive(editorView.state, schema.marks.code) : false; // Inline code formatting disabled for now + const isLinkActive = editorView && schema ? isMarkActive(editorView.state, schema.marks.link) : false; + + const isCurrentHeadingActive = editorView && schema + ? isNodeActive(editorView.state, schema.nodes.heading, { level: currentHeadingLevel }) + : false; + + const isBlockquoteActive = editorView && schema ? isNodeActive(editorView.state, schema.nodes.blockquote) : false; + const isBulletListActive = editorView && schema ? isListActive(editorView.state, schema.nodes.bullet_list) : false; + const isOrderedListActive = editorView && schema ? isListActive(editorView.state, schema.nodes.ordered_list) : false; + + const isUndoAvailable = editorView ? canUndo(editorView.state) : false; + const isRedoAvailable = editorView ? canRedo(editorView.state) : false; + + return ( + + + {helperPaneToggle && ( + + )} + + + + executeCommand(undoCommand)} + onMouseDown={handleMouseDown} + > + + + + executeCommand(redoCommand)} + onMouseDown={handleMouseDown} + > + + + + + + executeCommand(toggleBold)} + onMouseDown={handleMouseDown} + > + + + + executeCommand(toggleItalic)} + onMouseDown={handleMouseDown} + > + + + + {/* executeCommand(toggleCode)} + onMouseDown={handleMouseDown} + > + + */} + + + + + + + + + handleHeader()} + onMouseDown={handleMouseDown} + > + H{currentHeadingLevel} + + + + + + {[1, 2, 3, 4, 5, 6].map((level) => ( + handleHeader(level)} + onMouseDown={handleMouseDown} + > + Heading {level} + + ))} + + + + executeCommand(toggleBlockquote)} + onMouseDown={handleMouseDown} + > + + + + + + executeCommand(toggleBulletList)} + onMouseDown={handleMouseDown} + > + + + + executeCommand(toggleOrderedList)} + onMouseDown={handleMouseDown} + > + + + + + {onToggleView && ( + + )} + + setIsLinkDialogOpen(false)} + onInsert={handleInsertLink} + initialTitle={selectedTextForLink} + /> + + ); +}); + +RichTemplateMarkdownToolbar.displayName = 'RichTemplateMarkdownToolbar'; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx deleted file mode 100644 index a26e550497e..00000000000 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/TemplateMarkdownToolbar.tsx +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from "react"; -import styled from "@emotion/styled"; -import { ThemeColors, Icon } from "@wso2/ui-toolkit"; -import { EditorView } from "@codemirror/view"; -import { - insertMarkdownFormatting, - insertMarkdownHeader, - insertMarkdownLink, - insertMarkdownBlockquote, - insertMarkdownUnorderedList, - insertMarkdownOrderedList, - insertMarkdownTaskList -} from "../utils/templateUtils"; -import { HelperPaneToggleButton } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton"; - -const ToolbarContainer = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 4px; - padding: 8px 12px; - border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; - border-radius: 4px 4px 0 0; - flex-wrap: wrap; - font-family: GilmerMedium; -`; - -const ToolbarButtonGroup = styled.div` - display: flex; - align-items: center; - gap: 4px; - flex-wrap: wrap; -`; - -const ToolbarButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background-color: transparent; - color: ${ThemeColors.ON_SURFACE}; - border: 1px solid transparent; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background-color: ${ThemeColors.SECONDARY_CONTAINER}; - border-color: ${ThemeColors.OUTLINE}; - } - - &:active:not(:disabled) { - background-color: ${ThemeColors.SECONDARY_CONTAINER}; - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - &:focus-visible { - outline: 2px solid ${ThemeColors.PRIMARY}; - outline-offset: 2px; - } -`; - -const ToolbarDivider = styled.div` - width: 1px; - height: 24px; - background-color: ${ThemeColors.OUTLINE_VARIANT}; - margin: 0 4px; -`; - -interface TemplateMarkdownToolbarProps { - editorView: EditorView | null; - isPreviewMode?: boolean; - onTogglePreview?: () => void; - helperPaneToggle?: { - ref: React.RefObject; - isOpen: boolean; - onClick: () => void; - }; -} - -export const TemplateMarkdownToolbar = React.forwardRef(({ - editorView, - isPreviewMode = false, - onTogglePreview, - helperPaneToggle -}, ref) => { - // Prevent buttons from taking focus away from the editor - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - }; - - const handleBold = () => insertMarkdownFormatting(editorView, '**'); - const handleItalic = () => insertMarkdownFormatting(editorView, '_'); - const handleCode = () => insertMarkdownFormatting(editorView, '`'); - const handleLink = () => insertMarkdownLink(editorView); - const handleHeader = () => insertMarkdownHeader(editorView, 3); - const handleQuote = () => insertMarkdownBlockquote(editorView); - const handleUnorderedList = () => insertMarkdownUnorderedList(editorView); - const handleOrderedList = () => insertMarkdownOrderedList(editorView); - const handleTaskList = () => insertMarkdownTaskList(editorView); - - return ( - - - {helperPaneToggle && ( - - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}); - -TemplateMarkdownToolbar.displayName = 'TemplateMarkdownToolbar'; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx index 42fad8c2b02..388bf5122b7 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/ExpressionMode.tsx @@ -69,9 +69,9 @@ export const ExpressionMode: React.FC = ({ /> {error ? - : + : formDiagnostics && formDiagnostics.length > 0 && - d.message).join(', ')} /> + d.message).join(', ')} /> } ); diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx index 42681e0ab15..0475100838c 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/TemplateMode.tsx @@ -16,15 +16,15 @@ * under the License. */ -import React, { useEffect, useState, useRef } from "react"; +import React, { useState, useRef } from "react"; import styled from "@emotion/styled"; -import { EditorView } from "@codemirror/view"; +import { EditorView as CodeMirrorView } from "@codemirror/view"; +import { EditorView as ProseMirrorView } from "prosemirror-view"; import { EditorModeExpressionProps } from "./types"; import { ChipExpressionEditorComponent } from "../../MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; -import { TemplateMarkdownToolbar } from "../controls/TemplateMarkdownToolbar"; -import { MarkdownPreview } from "../controls/MarkdownPreview"; -import { transformExpressionToMarkdown } from "../utils/transformToMarkdown"; -import { useFormContext } from "../../../../context/form"; +import { RichTextTemplateEditor } from "../../MultiModeExpressionEditor/RichTextTemplateEditor/RichTextTemplateEditor"; +import { RichTemplateMarkdownToolbar } from "../controls/RichTemplateMarkdownToolbar"; +import { RawTemplateMarkdownToolbar } from "../controls/RawTemplateMarkdownToolbar"; import { ErrorBanner } from "@wso2/ui-toolkit"; import { RawTemplateEditorConfig } from "../../MultiModeExpressionEditor/Configurations"; @@ -34,7 +34,7 @@ const ExpressionContainer = styled.div` display: flex; flex-direction: column; box-sizing: border-box; - overflow: hidden; + overflow: hidden; `; export const TemplateMode: React.FC = ({ @@ -47,66 +47,26 @@ export const TemplateMode: React.FC = ({ extractArgsFromFunction, getHelperPane, rawExpression, - isPreviewMode = false, - onTogglePreview, error, - formDiagnostics + formDiagnostics, + inputMode }) => { - const [transformedContent, setTransformedContent] = useState(""); - const [editorView, setEditorView] = useState(null); + const [isSourceView, setIsSourceView] = useState(false); + const [codeMirrorView, setCodeMirrorView] = useState(null); + const [proseMirrorView, setProseMirrorView] = useState(null); const [helperPaneToggle, setHelperPaneToggle] = useState<{ ref: React.RefObject; isOpen: boolean; onClick: () => void; } | null>(null); - const toolbarRef = useRef(null); - const { expressionEditor } = useFormContext(); - const expressionEditorRpcManager = expressionEditor?.rpcManager; + const richToolbarRef = useRef(null); + const rawToolbarRef = useRef(null); // Convert onChange signature from (value: string) => void to (value: string, cursorPosition: number) => void const handleChange = (updatedValue: string, updatedCursorPosition: number) => { onChange(updatedValue, updatedCursorPosition); }; - // Transform expression to markdown when entering preview mode - useEffect(() => { - const transformContent = async () => { - if (isPreviewMode && value && expressionEditorRpcManager) { - try { - // Fetch token stream from language server - const startLine = targetLineRange?.startLine; - const tokenStream = await expressionEditorRpcManager.getExpressionTokens( - value, - fileName, - startLine !== undefined ? startLine : undefined - ); - - // Get sanitized value for display - const displayValue = sanitizedExpression ? sanitizedExpression(value) : value; - - if (tokenStream && tokenStream.length > 0) { - // Transform expression with tokens to markdown with chip tags - const markdown = transformExpressionToMarkdown(displayValue, tokenStream); - setTransformedContent(markdown); - } else { - // No tokens, use sanitized value as-is - setTransformedContent(displayValue); - } - } catch (error) { - console.error('Error transforming expression to markdown:', error); - // Fallback to sanitized value on error - const displayValue = sanitizedExpression ? sanitizedExpression(value) : value; - setTransformedContent(displayValue); - } - } - }; - - transformContent(); - }, [isPreviewMode, value, fileName, targetLineRange?.startLine, expressionEditorRpcManager, sanitizedExpression]); - - // Only show toolbar and preview if preview props are provided - const hasPreviewSupport = onTogglePreview !== undefined; - const handleHelperPaneStateChange = (state: { isOpen: boolean; ref: React.RefObject; @@ -119,20 +79,30 @@ export const TemplateMode: React.FC = ({ }); }; + const handleToggleView = () => { + setIsSourceView(!isSourceView); + }; + return ( <> - {hasPreviewSupport && ( - onTogglePreview(!isPreviewMode)} + {isSourceView ? ( + - )} - {isPreviewMode ? ( - ) : ( + + )} + {isSourceView ? ( = ({ isExpandedVersion={true} showHelperPaneToggle={false} onHelperPaneStateChange={handleHelperPaneStateChange} - onEditorViewReady={setEditorView} - toolbarRef={toolbarRef} + onEditorViewReady={setCodeMirrorView} + toolbarRef={rawToolbarRef} enableListContinuation={true} configuration={new RawTemplateEditorConfig()} + inputMode={inputMode} /> - )} + ) : ( + + + + ) + } {error ? : formDiagnostics && formDiagnostics.length > 0 && diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts index ce2ca564be5..8dc89026dc9 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/modes/types.ts @@ -21,6 +21,7 @@ import { CompletionItem, FnSignatureDocumentation, HelperPaneHeight } from "@wso import { LineRange } from "@wso2/ballerina-core/lib/interfaces/common"; import { DiagnosticMessage } from "@wso2/ballerina-core"; import { FieldError } from "react-hook-form"; +import { InputMode } from "../.."; /** * Base props that all editor mode components must implement @@ -78,6 +79,8 @@ export interface EditorModeExpressionProps extends EditorModeProps { error?: FieldError; /** Form diagnostics messages */ formDiagnostics?: DiagnosticMessage[]; + /** Input mode */ + inputMode?: InputMode; } /** diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts index b7d4ea6320e..5c908cbd774 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/utils/templateUtils.ts @@ -17,6 +17,7 @@ */ import { EditorView, KeyBinding } from "@codemirror/view"; +import { undo, redo, undoDepth, redoDepth } from "@codemirror/commands"; /** * Inserts or removes markdown formatting around selected text (toggles) @@ -215,7 +216,7 @@ const toggleList = (view: EditorView | null, config: ListConfig) => { const allListed = lines.every(line => { const trimmed = line.trim(); - return trimmed === "" || config.isListed(trimmed); + return trimmed !== "" && config.isListed(trimmed); }); const processedLines = lines.map((line, index) => { @@ -264,14 +265,6 @@ export const insertMarkdownOrderedList = (view: EditorView | null) => { }); }; -export const insertMarkdownTaskList = (view: EditorView | null) => { - toggleList(view, { - isListed: t => /^-\s\[[ x]\]\s/.test(t), - strip: t => t.replace(/^-\s\[[ x]\]\s/, ""), - add: t => `- [ ] ${t}` - }); -}; - // --- List Continuation on Enter --- interface ListPattern { @@ -351,3 +344,41 @@ export const listContinuationKeymap: KeyBinding[] = [ run: handleEnterForListContinuation } ]; + +// --- Undo/Redo Commands --- + +/** + * Executes undo command + */ +export const undoCommand = (view: EditorView | null) => { + if (!view) return false; + const result = undo(view); + view.focus(); + return result; +}; + +/** + * Executes redo command + */ +export const redoCommand = (view: EditorView | null) => { + if (!view) return false; + const result = redo(view); + view.focus(); + return result; +}; + +/** + * Checks if undo is available + */ +export const canUndo = (view: EditorView | null): boolean => { + if (!view) return false; + return undoDepth(view.state) > 0; +}; + +/** + * Checks if redo is available + */ +export const canRedo = (view: EditorView | null): boolean => { + if (!view) return false; + return redoDepth(view.state) > 0; +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx index d0eb01de2b2..f5f269ccbbf 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpressionEditor.tsx @@ -408,14 +408,14 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { fetchedInitialDiagnostics: false, diagnosticsFetchedTargetLineRange: undefined }); - const fieldValue = inputModeRef.current === InputMode.TEMPLATE && rawExpression ? rawExpression(watch(key)) : watch(key); + const fieldValue = (inputModeRef.current === InputMode.PROMPT || inputModeRef.current === InputMode.TEMPLATE) && rawExpression ? rawExpression(watch(key)) : watch(key); // Initial render useEffect(() => { if (!targetLineRange) return; // Fetch initial diagnostics if (getExpressionEditorDiagnostics && fieldValue !== undefined - && (inputMode === InputMode.EXP || inputMode === InputMode.TEMPLATE) + && (inputMode === InputMode.EXP || inputMode === InputMode.PROMPT || inputMode === InputMode.TEMPLATE) && (previousDiagnosticsFetchContext.current.fetchedInitialDiagnostics === false || previousDiagnosticsFetchContext.current.diagnosticsFetchedTargetLineRange !== targetLineRange )) { @@ -445,9 +445,25 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setInputMode(InputMode.EXP); return; } - if (isModeSwitcherRestricted()) { - setInputMode(InputMode.EXP); - return; + if (newInputMode === InputMode.TEXT + && typeof initialFieldValue.current === 'string' + && initialFieldValue.current.trim() !== '' + && !(initialFieldValue.current.trim().startsWith("\"") + && initialFieldValue.current.trim().endsWith("\"") + ) + ) { + setInputMode(InputMode.EXP) + } else if (newInputMode === InputMode.PROMPT || newInputMode === InputMode.TEMPLATE) { + if (sanitizedExpression && rawExpression) { + const sanitized = sanitizedExpression(initialFieldValue.current as string); + if (sanitized !== initialFieldValue.current || !initialFieldValue.current || initialFieldValue.current.trim() === '') { + setInputMode(newInputMode); + } else { + setInputMode(InputMode.EXP); + } + } + } else { + setInputMode(newInputMode); } switch (newInputMode) { case (InputMode.BOOLEAN): @@ -557,11 +573,34 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { } break; } + + // Warn when switching from EXP to TEMPLATE if sanitization would hide parts of the expression + if ( + inputMode === InputMode.EXP + && (value === InputMode.PROMPT || value === InputMode.TEMPLATE) + && sanitizedExpression + && currentValue + && currentValue.trim() !== '' + ) { + targetInputModeRef.current = value; + if (currentValue === sanitizedExpression(currentValue)) { + setShowModeSwitchWarning(true); + } else { + setInputMode(value); + } + return; + } setInputMode(value); }; const handleModeSwitchWarningContinue = () => { - setInputMode(targetInputModeRef.current); + if (targetInputModeRef.current !== null) { + setInputMode(targetInputModeRef.current); + if ((targetInputModeRef.current === InputMode.PROMPT || targetInputModeRef.current === InputMode.TEMPLATE) && inputMode === InputMode.EXP && rawExpression) { + setValue(key, rawExpression("")); + } + targetInputModeRef.current = null; + } setShowModeSwitchWarning(false); }; @@ -583,7 +622,7 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { // Only allow opening expanded mode for specific fields or expression mode const onOpenExpandedMode = (!props.isInExpandedMode && - (["query", "instructions", "role"].includes(field.key) || inputMode === InputMode.EXP || inputMode === InputMode.TEMPLATE)) + (["query", "instructions", "role"].includes(field.key) || inputMode === InputMode.EXP || inputMode === InputMode.PROMPT || inputMode === InputMode.TEMPLATE)) ? handleOpenExpandedMode : undefined; @@ -668,6 +707,8 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { fileName={effectiveFileName} targetLineRange={effectiveTargetLineRange} autoFocus={recordTypeField ? false : autoFocus} + sanitizedExpression={(inputMode === InputMode.PROMPT || inputMode === InputMode.TEMPLATE) ? sanitizedExpression : undefined} + rawExpression={(inputMode === InputMode.PROMPT || inputMode === InputMode.TEMPLATE) ? rawExpression : undefined} ariaLabel={field.label} placeholder={placeholder} onChange={async (updatedValue: string, updatedCursorPosition: number) => { @@ -676,9 +717,10 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setFormDiagnostics([]); // Use ref to get current mode (not stale closure value) const currentMode = inputModeRef.current; + const rawValue = (currentMode === InputMode.PROMPT || currentMode === InputMode.TEMPLATE) && rawExpression ? rawExpression(updatedValue) : updatedValue; - onChange(updatedValue); - if (getExpressionEditorDiagnostics && (currentMode === InputMode.EXP || currentMode === InputMode.TEMPLATE)) { + onChange(rawValue); + if (getExpressionEditorDiagnostics && (currentMode === InputMode.EXP || currentMode === InputMode.PROMPT || currentMode === InputMode.TEMPLATE)) { getExpressionEditorDiagnostics( (required ?? !field.optional) || updatedValue !== '', updatedValue, @@ -745,10 +787,10 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setFormDiagnostics([]); // Use ref to get current mode (not stale closure value) const currentMode = inputModeRef.current; - const rawValue = currentMode === InputMode.TEMPLATE && rawExpression ? rawExpression(updatedValue) : updatedValue; + const rawValue = (currentMode === InputMode.PROMPT || currentMode === InputMode.TEMPLATE) && rawExpression ? rawExpression(updatedValue) : updatedValue; onChange(rawValue); - if (getExpressionEditorDiagnostics && (currentMode === InputMode.EXP || currentMode === InputMode.TEMPLATE)) { + if (getExpressionEditorDiagnostics && (currentMode === InputMode.EXP || currentMode === InputMode.PROMPT || currentMode === InputMode.TEMPLATE)) { getExpressionEditorDiagnostics( (required ?? !field.optional) || rawValue !== '', rawValue, @@ -781,16 +823,17 @@ export const ExpressionEditor = (props: ExpressionEditorProps) => { setIsExpandedModalOpen(false) }} onSave={handleSaveExpandedMode} - mode={inputModeRef.current === InputMode.EXP ? "expression" : inputModeRef.current === InputMode.TEMPLATE ? "template" : undefined} + mode={inputModeRef.current === InputMode.EXP ? "expression" : inputModeRef.current === InputMode.PROMPT ? "template" : undefined} completions={completions} fileName={effectiveFileName} targetLineRange={effectiveTargetLineRange} - sanitizedExpression={inputModeRef.current === InputMode.TEMPLATE ? sanitizedExpression : undefined} - rawExpression={inputModeRef.current === InputMode.TEMPLATE ? rawExpression : undefined} + sanitizedExpression={sanitizedExpression} + rawExpression={rawExpression} extractArgsFromFunction={handleExtractArgsFromFunction} getHelperPane={handleGetHelperPane} error={error} formDiagnostics={formDiagnostics} + inputMode={inputModeRef.current} /> )} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts index c836497113f..9d459b9d10c 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/CodeUtils.ts @@ -24,6 +24,7 @@ import { CompletionItem, FnSignatureDocumentation } from "@wso2/ui-toolkit"; import { ThemeColors } from "@wso2/ui-toolkit"; import { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; import { TokenType, TokenMetadata, CompoundTokenSequence } from "./types"; +import { Extension } from '@codemirror/state'; import { CHIP_TEXT_STYLES, BASE_CHIP_STYLES, @@ -594,7 +595,7 @@ export const buildCompletionSource = (getCompletions: () => Promise boolean, onClose: () => void) => { +export const buildHelperPaneKeymap = (getIsHelperPaneOpen: () => boolean, onClose: () => void, onToggle?: () => void) => { return [ { key: "Escape", @@ -603,7 +604,15 @@ export const buildHelperPaneKeymap = (getIsHelperPaneOpen: () => boolean, onClos onClose(); return true; } - } + }, + ...(onToggle ? [{ + key: "Ctrl-/", + mac: "Cmd-/", + run: (_view: EditorView) => { + onToggle(); + return true; + } + }] : []) ]; }; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts index 35d76870faf..efde2b30824 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/chipStyles.ts @@ -64,9 +64,9 @@ export const TOKEN_TYPE_COLORS: Partial void; onTokenClick?: (token: string) => void; @@ -98,6 +97,7 @@ export type ChipExpressionEditorComponentProps = { toolbarRef?: React.RefObject; enableListContinuation?: boolean; configuration?: ChipExpressionEditorDefaultConfiguration; + inputMode?: InputMode; } export const ChipExpressionEditorComponent = (props: ChipExpressionEditorComponentProps) => { @@ -111,7 +111,6 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const completionsRef = useRef(props.completions); const helperPaneToggleButtonRef = useRef(null); const completionsFetchScheduledRef = useRef(false); - const savedSelectionRef = useRef<{ from: number; to: number } | null>(null); const { expressionEditor } = useFormContext(); const expressionEditorRpcManager = expressionEditor?.rpcManager; @@ -149,6 +148,25 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const tooltipExtension = hoverTooltip((view, pos) => { return docTooltip(view, pos); }); + // Helper pane state management with conditional fixed placement for toolbar toggle in PROMPT mode + const { helperPaneState, setHelperPaneState, handleManualToggle, handleKeyboardToggle } = useHelperPane( + { + editorRef, + toggleButtonRef: helperPaneToggleButtonRef, + helperPaneWidth: HELPER_PANE_WIDTH, + onStateChange: props.onHelperPaneStateChange, + customManualToggle: props.inputMode === InputMode.PROMPT && props.isInExpandedMode ? (setHelperPaneState) => { + if (!editorRef?.current) return; + + setHelperPaneState(prev => { + if (prev.isOpen) return { ...prev, isOpen: false }; + const scrollTop = editorRef.current!.scrollTop || 0; + return { isOpen: true, top: scrollTop, left: 10 }; + }); + } : undefined + }, + () => viewRef.current?.coordsAtPos(viewRef.current.state.selection.main.head) || null + ); const needTokenRefetchListner = buildNeedTokenRefetchListner(() => { setIsTokenUpdateScheduled(true); @@ -163,7 +181,6 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const isRangeSelection = cursor.position.to !== cursor.position.from; if (newValue === '' || isTrigger || isRangeSelection) { - savedSelectionRef.current = { from: cursor.position.from, to: cursor.position.to }; setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); } else { setHelperPaneState({ isOpen: false, top: 0, left: 0 }); @@ -171,12 +188,10 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone }); const handleFocusListner = buildOnFocusListner((cursor: CursorInfo) => { - savedSelectionRef.current = { from: cursor.position.from, to: cursor.position.to }; setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); }); const handleSelectionChange = buildOnSelectionChange((cursor: CursorInfo) => { - savedSelectionRef.current = { from: cursor.position.from, to: cursor.position.to }; setHelperPaneState({ isOpen: true, top: cursor.top, left: cursor.left }); }); @@ -201,16 +216,20 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone return buildCompletionSource(waitForStateChange); }, [props.completions]); - const helperPaneKeymap = buildHelperPaneKeymap(() => helperPaneState.isOpen, () => { - setHelperPaneState(prev => ({ ...prev, isOpen: false })); - }); + const helperPaneKeymap = buildHelperPaneKeymap( + () => helperPaneState.isOpen, + () => { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + }, + handleKeyboardToggle + ); const onHelperItemSelect = async (value: string, options: HelperpaneOnChangeOptions) => { if (!viewRef.current) return; const view = viewRef.current; // Use saved selection if available, otherwise fall back to current selection - const currentSelection = savedSelectionRef.current || view.state.selection.main; + const currentSelection = view.state.selection.main; const { from, to } = options?.replaceFullText ? { from: 0, to: view.state.doc.length } : currentSelection; const selectionIsOnToken = isSelectionOnToken(currentSelection.from, currentSelection.to, view); @@ -267,9 +286,6 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone setIsTokenUpdateScheduled(true); } setHelperPaneState(prev => ({ ...prev, isOpen: !options.closeHelperPane })); - - // Clear saved selection after use - savedSelectionRef.current = null; } const handleHelperPaneManualToggle = () => { @@ -436,40 +452,23 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone setIsTokenUpdateScheduled(true); }, [Boolean(props.sanitizedExpression), Boolean(props.rawExpression)]); - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (!helperPaneState.isOpen) return; - - const target = event.target as Element; - const isClickInsideEditor = editorRef.current?.contains(target); - const isClickInsideHelperPane = helperPaneRef.current?.contains(target); - const isClickOnToggleButton = helperPaneToggleButtonRef.current?.contains(target); - const isClickInsideToolbar = props.toolbarRef?.current?.contains(target); - - if (!isClickInsideEditor && !isClickInsideHelperPane && !isClickOnToggleButton && !isClickInsideToolbar) { - setHelperPaneState(prev => ({ ...prev, isOpen: false })); - viewRef.current?.dom.blur(); - } - }; - - const handleEscapeKey = (event: KeyboardEvent) => { - if (!helperPaneState.isOpen) return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - setHelperPaneState(prev => ({ ...prev, isOpen: false })); - } - }; - - if (helperPaneState.isOpen) { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscapeKey); + // Handle click outside and escape key for helper pane + useHelperPaneClickOutside({ + enabled: helperPaneState.isOpen, + refs: { + editor: editorRef, + helperPane: helperPaneRef, + toggleButton: helperPaneToggleButtonRef, + toolbar: props.toolbarRef + }, + onClickOutside: () => { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + viewRef.current?.dom.blur(); + }, + onEscapeKey: () => { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscapeKey); - }; - }, [helperPaneState.isOpen, props.toolbarRef]); + }); const showToggle = props.showHelperPaneToggle !== false && props.isExpandedVersion; @@ -479,7 +478,8 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone )} {helperPaneState.isOpen ? : } diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx index 0142784679b..eedea6318b8 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/HelperPaneToggleButton.tsx @@ -26,13 +26,15 @@ interface HelperPaneToggleButtonProps { onClick: () => void; sx?: React.CSSProperties; disabled?: boolean; + title?: string; + displayText?: string; } -const OutlineButton = styled.button<{ isOpen: boolean}>` +const OutlineButton = styled.button<{ isOpen: boolean }>` padding: 6px 12px; border-radius: 3px; border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; - background-color: ${props => props.isOpen + background-color: ${(props: { isOpen: boolean }) => props.isOpen ? ThemeColors.SURFACE : ThemeColors.SURFACE_BRIGHT}; color: ${ThemeColors.ON_SURFACE}; @@ -82,7 +84,9 @@ export const HelperPaneToggleButton = React.forwardRef { return ( @@ -96,9 +100,10 @@ export const HelperPaneToggleButton = React.forwardRef {isOpen ? : } - Helper Panel + {displayText ? displayText : "Helper Panel"} ); }); diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/constants.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/constants.ts index 8ecaacd8905..5622c09ae66 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/constants.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/constants.ts @@ -38,3 +38,6 @@ export const ARROW_LEFT_MARKER = '#$ARROWLEFT'; export const FOCUS_MARKER = '#$FOCUS'; export const COMPLETIONS_MARKER = '#$COMPLETIONS'; export const HELPER_MARKER = '#$HELPER'; + +// Helper pane dimensions +export const HELPER_PANE_WIDTH = 300; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/hooks/useHelperPane.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/hooks/useHelperPane.ts new file mode 100644 index 00000000000..b5b82ae8040 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/hooks/useHelperPane.ts @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState, useCallback } from "react"; +import { HelperPaneState } from "../types"; +import { calculateHelperPanePosition } from "../utils"; + +export interface UseHelperPaneClickOutsideConfig { + enabled: boolean; + refs: { + editor: React.RefObject; + helperPane: React.RefObject; + toggleButton: React.RefObject; + toolbar?: React.RefObject; + }; + onClickOutside: () => void; + onEscapeKey: () => void; +} + +// Hook to handle click outside and escape key press +export const useHelperPaneClickOutside = (config: UseHelperPaneClickOutsideConfig): void => { + const { enabled, refs, onClickOutside, onEscapeKey } = config; + + useEffect(() => { + if (!enabled) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + const isClickInsideEditor = refs.editor.current?.contains(target); + const isClickInsideHelperPane = refs.helperPane.current?.contains(target); + const isClickOnToggleButton = refs.toggleButton.current?.contains(target); + const isClickInsideToolbar = refs.toolbar?.current?.contains(target); + + if (!isClickInsideEditor && !isClickInsideHelperPane && !isClickOnToggleButton && !isClickInsideToolbar) { + onClickOutside(); + } + }; + + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onEscapeKey(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscapeKey); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [enabled, refs.editor, refs.helperPane, refs.toggleButton, refs.toolbar, onClickOutside, onEscapeKey]); +}; + +export interface HelperPaneStateChangeCallback { + isOpen: boolean; + ref: React.RefObject; + toggle: () => void; +} + +export interface UseHelperPaneConfig { + editorRef: React.RefObject; + toggleButtonRef: React.RefObject; + helperPaneWidth: number; + onStateChange?: (state: HelperPaneStateChangeCallback) => void; + customManualToggle?: (setHelperPaneState: React.Dispatch>) => void; +} + +export interface UseHelperPaneReturn { + helperPaneState: HelperPaneState; + setHelperPaneState: React.Dispatch>; + handleManualToggle: () => void; + handleKeyboardToggle: () => boolean; +} + +// Function to get cursor coordinates - abstracts over CodeMirror vs ProseMirror +export type GetCursorCoords = () => { bottom: number; left: number } | null; + +// Hook to manage helper pane state and toggle handlers +export const useHelperPane = ( + config: UseHelperPaneConfig, + getCursorCoords: GetCursorCoords +): UseHelperPaneReturn => { + const { editorRef, toggleButtonRef, helperPaneWidth, onStateChange, customManualToggle } = config; + + const [helperPaneState, setHelperPaneState] = useState({ + isOpen: false, + top: 0, + left: 0 + }); + + const handleManualToggle = useCallback(() => { + if (customManualToggle) { + customManualToggle(setHelperPaneState); + return; + } + + if (!toggleButtonRef?.current || !editorRef?.current) return; + + setHelperPaneState(prev => { + if (prev.isOpen) { + return { ...prev, isOpen: false }; + } + + const buttonRect = toggleButtonRef.current!.getBoundingClientRect(); + const editorRect = editorRef.current!.getBoundingClientRect(); + const scrollTop = editorRef.current!.scrollTop || 0; + const position = calculateHelperPanePosition(buttonRect, editorRect, helperPaneWidth, scrollTop); + + return { ...prev, ...position, isOpen: true }; + }); + }, [customManualToggle, toggleButtonRef, editorRef, helperPaneWidth]); + + const handleKeyboardToggle = useCallback((): boolean => { + if (!editorRef?.current) return false; + + setHelperPaneState(prev => { + if (prev.isOpen) { + return { ...prev, isOpen: false }; + } + + const cursorCoords = getCursorCoords(); + if (cursorCoords) { + const editorRect = editorRef.current!.getBoundingClientRect(); + const scrollTop = editorRef.current!.scrollTop || 0; + const position = calculateHelperPanePosition(cursorCoords, editorRect, helperPaneWidth, scrollTop); + return { isOpen: true, ...position }; + } + + const scrollTop = editorRef.current!.scrollTop || 0; + return { isOpen: true, top: scrollTop, left: 10 }; + }); + + return true; + }, [editorRef, getCursorCoords, helperPaneWidth]); + + useEffect(() => { + if (onStateChange) { + onStateChange({ + isOpen: helperPaneState.isOpen, + ref: toggleButtonRef, + toggle: handleManualToggle + }); + } + }, [helperPaneState.isOpen]); + + return { + helperPaneState, + setHelperPaneState, + handleManualToggle, + handleKeyboardToggle + }; +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts index b30e16df0c8..bc6d59f96d5 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/types.ts @@ -23,15 +23,16 @@ export enum InputMode { TEMPLATE = "Template", NUMBER = "Number", BOOLEAN = "Boolean", - SQL = "SQL" -} + SQL = "SQL", + PROMPT = "Prompt" +}; export const INPUT_MODE_MAP = { string: InputMode.TEXT, - "ai:Prompt": InputMode.TEMPLATE, int: InputMode.NUMBER, boolean: InputMode.BOOLEAN, - "sql:ParameterizedQuery": InputMode.SQL + "sql:ParameterizedQuery": InputMode.SQL, + "ai:Prompt": InputMode.PROMPT }; export enum TokenType { @@ -109,3 +110,12 @@ export type TokenPattern = { extractor: (tokens: any[], startIndex: number, endIndex: number, docText: string) => TokenMetadata | null; priority: number; }; + +// Helper pane state management +export type HelperPaneState = { + isOpen: boolean; + top: number; + left: number; + clickedChipPos?: number; + clickedChipNode?: any; +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts index 20d55cce582..43b2d910ef8 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/utils.ts @@ -18,6 +18,7 @@ import { CompletionItem } from "@wso2/ui-toolkit"; import { INPUT_MODE_MAP, InputMode, TokenType, CompoundTokenSequence, TokenMetadata, DocumentType, TokenPattern } from "./types"; +import { FnSignatureDocumentation } from "@wso2/ui-toolkit"; export const TOKEN_LINE_OFFSET_INDEX = 0; export const TOKEN_START_CHAR_OFFSET_INDEX = 1; @@ -329,3 +330,87 @@ export const detectTokenPatterns = ( return compounds; }; + +// Calculates helper pane position with viewport overflow correction +export const calculateHelperPanePosition = ( + targetCoords: { bottom: number; left: number }, + editorRect: DOMRect, + helperPaneWidth: number, + scrollTop: number = 0 +): { top: number; left: number } => { + // Position relative to the editor container, accounting for scroll + let top = targetCoords.bottom - editorRect.top + scrollTop; + let left = targetCoords.left - editorRect.left; + + // Add overflow correction for window boundaries + const viewportWidth = window.innerWidth; + const absoluteLeft = targetCoords.left; + const overflow = absoluteLeft + helperPaneWidth - viewportWidth; + + if (overflow > 0) { + left -= overflow; + } + + return { top, left }; +}; + +export interface FunctionExtractionResult { + finalValue: string; + cursorAdjustment: number; // How much to adjust cursor position from base position +} + +// Processes a value that ends with () or )}, extracting function arguments and creating placeholders +export const processFunctionWithArguments = async ( + value: string, + basePosition: number, + extractArgsFromFunction: (value: string, cursorPosition: number) => Promise<{ + label: string; + args: string[]; + currentArgIndex: number; + documentation?: FnSignatureDocumentation; + }> +): Promise => { + try { + // Extract the function definition from string templates like "${func()}" + let functionDef = value; + let prefix = ''; + let suffix = ''; + + // Check if it's within a string template + const stringTemplateMatch = value.match(/^(.*\$\{)([^}]+)(\}.*)$/); + if (stringTemplateMatch) { + prefix = stringTemplateMatch[1]; + functionDef = stringTemplateMatch[2]; + suffix = stringTemplateMatch[3]; + } + + // Calculate cursor position for extraction relative to the functionDef string + let cursorPositionForExtraction = functionDef.length - 1; + if (functionDef.endsWith(')}')) { + cursorPositionForExtraction -= 1; + } + + // Extract function signature from backend + const fnSignature = await extractArgsFromFunction(functionDef, cursorPositionForExtraction); + + if (fnSignature && fnSignature.args && fnSignature.args.length > 0) { + // Generate placeholder arguments: $1, $2, $3, etc. + const placeholderArgs = fnSignature.args.map((_arg, index) => `$${index + 1}`); + const updatedFunctionDef = functionDef.slice(0, -2) + '(' + placeholderArgs.join(', ') + ')'; + const finalValue = prefix + updatedFunctionDef + suffix; + + // Cursor adjustment is relative to the start of the inserted value + const closingParenIndex = finalValue.lastIndexOf(")"); + const cursorAdjustment = + closingParenIndex >= 0 ? closingParenIndex : finalValue.length; + + return { finalValue, cursorAdjustment }; + } + } catch (error) { + console.warn('Failed to extract function arguments:', error); + } + + // Return original value if extraction failed or no arguments + // Keep caret at the end of the inserted snippet. + return { finalValue: value, cursorAdjustment: value.length }; +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/RichTextTemplateEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/RichTextTemplateEditor.tsx new file mode 100644 index 00000000000..486d6844de7 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/RichTextTemplateEditor.tsx @@ -0,0 +1,513 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useEffect, useRef, useState } from "react"; +import styled from "@emotion/styled"; +import { EditorState, Plugin } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { keymap } from "prosemirror-keymap"; +import { history, undo, redo } from "prosemirror-history"; +import { baseKeymap } from "prosemirror-commands"; +import { gapCursor } from "prosemirror-gapcursor"; +import { splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list"; +import { defaultMarkdownParser, defaultMarkdownSerializer, MarkdownParser, MarkdownSerializer } from "prosemirror-markdown"; +import markdownit from "markdown-it"; +import { ThemeColors, CompletionItem, FnSignatureDocumentation, HelperPaneHeight } from "@wso2/ui-toolkit"; +import { LineRange } from "@wso2/ballerina-core/lib/interfaces/common"; +import { HelperpaneOnChangeOptions } from "../../../Form/types"; +import { useFormContext } from "../../../../context/form"; +import { createChipPlugin, createChipSchema, updateChipTokens } from "./chipPlugin"; +import { HelperPane } from "../ChipExpressionEditor/components/HelperPane"; +import { + toggleBold, + toggleItalic, + toggleHeading, + toggleBlockquote, + toggleBulletList, + toggleOrderedList +} from "./markdownCommands"; +import { HELPER_PANE_WIDTH } from "../ChipExpressionEditor/constants"; +import { calculateHelperPanePosition, processFunctionWithArguments } from "../ChipExpressionEditor/utils"; +import { useHelperPaneClickOutside, useHelperPane } from "../ChipExpressionEditor/hooks/useHelperPane"; + +const EditorContainer = styled.div` + flex: 1; + overflow: auto; + border: 1px solid ${ThemeColors.OUTLINE_VARIANT}; + border-radius: 0 0 3px 3px; + border-top: none; + background-color: var(--vscode-input-background); + position: relative; + + .ProseMirror { + padding: 8px 12px; + outline: none; + min-height: 100%; + font-family: var(--vscode-font-family); + font-size: 13px; + color: ${ThemeColors.ON_SURFACE}; + line-height: 1.6; + } + + .ProseMirror p { + margin: 0.5em 0; + } + + .ProseMirror h1, + .ProseMirror h2, + .ProseMirror h3, + .ProseMirror h4, + .ProseMirror h5, + .ProseMirror h6 { + margin: 0.5em 0; + font-weight: 600; + } + + .ProseMirror h1 { font-size: 2em; } + .ProseMirror h2 { font-size: 1.5em; } + .ProseMirror h3 { font-size: 1.25em; } + + .ProseMirror ul, + .ProseMirror ol { + margin: 0.5em 0; + padding-left: 2em; + } + + .ProseMirror blockquote { + margin: 1em 0; + padding-left: 1em; + padding-top: 0.1em; + padding-bottom: 0.1em; + border-left: 3px solid ${ThemeColors.PRIMARY}; + color: ${ThemeColors.ON_SURFACE_VARIANT}; + } + + .ProseMirror code { + background: ${ThemeColors.SURFACE_BRIGHT}; + padding: 2px 4px; + border-radius: 3px; + font-family: var(--vscode-editor-font-family); + font-size: 0.9em; + } + + .ProseMirror pre { + background: ${ThemeColors.SURFACE_BRIGHT}; + padding: 8px; + border-radius: 3px; + overflow-x: auto; + } + + .ProseMirror pre code { + background: none; + padding: 0; + } +`; + +const markdownTokenizer = markdownit("commonmark", { html: false }).disable(["autolink", "html_inline", "html_block"]); + +// Create chip schema once +const chipSchema = createChipSchema(); + +const customMarkdownParser = new MarkdownParser( + chipSchema, + markdownTokenizer, + defaultMarkdownParser.tokens +); + +// Create custom serializer that handles chip nodes +const customMarkdownSerializer = new MarkdownSerializer( + { + ...defaultMarkdownSerializer.nodes, + chip(state: any, node: any) { + // Serialize chip nodes back to their original text + state.text(node.attrs.text, false); + } + }, + defaultMarkdownSerializer.marks +); + +interface RichTextTemplateEditorProps { + value: string; + onChange: (value: string, cursorPosition: number) => void; + completions?: CompletionItem[]; + fileName?: string; + targetLineRange?: LineRange; + sanitizedExpression?: (value: string) => string; + rawExpression?: (value: string) => string; + extractArgsFromFunction?: (value: string, cursorPosition: number) => Promise<{ + label: string; + args: string[]; + currentArgIndex: number; + documentation?: FnSignatureDocumentation; + }>; + getHelperPane?: ( + value: string, + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, + helperPaneHeight: HelperPaneHeight + ) => React.ReactNode; + onEditorViewReady?: (view: EditorView) => void; + onHelperPaneStateChange?: (state: { + isOpen: boolean; + ref: React.RefObject; + toggle: () => void; + }) => void; +} + +export const RichTextTemplateEditor: React.FC = ({ + value, + onChange, + fileName, + targetLineRange, + sanitizedExpression, + rawExpression, + onEditorViewReady, + getHelperPane, + onHelperPaneStateChange, + extractArgsFromFunction, +}) => { + const editorRef = useRef(null); + const viewRef = useRef(null); + const helperPaneRef = useRef(null); + const helperPaneToggleButtonRef = useRef(null); + const toolbarRef = useRef(null); + const pendingTokenFetchRef = useRef(false); + + const { expressionEditor } = useFormContext(); + const rpcManager = expressionEditor?.rpcManager; + + // Helper pane state management with fixed placement for toolbar button + const { helperPaneState, setHelperPaneState, handleKeyboardToggle } = useHelperPane( + { + editorRef, + toggleButtonRef: helperPaneToggleButtonRef, + helperPaneWidth: HELPER_PANE_WIDTH, + onStateChange: onHelperPaneStateChange, + customManualToggle: (setHelperPaneState) => { + if (!editorRef?.current || !viewRef.current) return; + + setHelperPaneState(prev => { + if (prev.isOpen) { + return { ...prev, isOpen: false }; + } + + const scrollTop = editorRef.current!.scrollTop || 0; + return { isOpen: true, top: scrollTop, left: 10 }; + }); + } + }, + () => { + const view = viewRef.current; + if (!view) return null; + const cursorPos = view.state.selection.$head.pos; + return view.coordsAtPos(cursorPos) || null; + } + ); + + // Handle chip click to show helper pane + const handleChipClick = (event: MouseEvent, chipPos: number, chipNode: any) => { + if (!viewRef.current || !editorRef.current) return; + + const target = event.target as HTMLElement; + const chipElement = target.closest('.pm-chip') as HTMLElement; + if (!chipElement) return; + + const chipRect = chipElement.getBoundingClientRect(); + const editorRect = editorRef.current.getBoundingClientRect(); + + // Get the scroll position from the editor container + const scrollTop = editorRef.current.scrollTop || 0; + + const position = calculateHelperPanePosition(chipRect, editorRect, HELPER_PANE_WIDTH, scrollTop); + + setHelperPaneState({ + isOpen: true, + ...position, + clickedChipPos: chipPos, + clickedChipNode: chipNode + }); + }; + + // Handle helper pane selection + const onHelperItemSelect = async (newValue: string, options?: HelperpaneOnChangeOptions) => { + if (!viewRef.current) return; + + const view = viewRef.current; + let finalValue = newValue; + let cursorPosition = view.state.selection.from; + + // If a chip was clicked, replace it + if (helperPaneState.clickedChipPos !== undefined && helperPaneState.clickedChipNode) { + const chipPos = helperPaneState.clickedChipPos; + const chipNode = helperPaneState.clickedChipNode; + const chipSize = chipNode.nodeSize; + + // HACK: this should be handled properly with completion items template + if (newValue.endsWith('()') || newValue.endsWith(')}')) { + if (extractArgsFromFunction) { + const result = await processFunctionWithArguments(newValue, chipPos, extractArgsFromFunction); + finalValue = result.finalValue; + } + } + + // Replace the chip with the new text + const textNode = view.state.schema.text(finalValue); + const tr = view.state.tr; + (tr as any).replaceRangeWith(chipPos, chipPos + chipSize, textNode); + view.dispatch(tr); + + cursorPosition = chipPos + finalValue.length; + } else { + // Insert at current cursor position + const { from, to } = view.state.selection; + const tr = view.state.tr.insertText(finalValue, from, to); + view.dispatch(tr); + cursorPosition = from + finalValue.length; + } + + setHelperPaneState({ + isOpen: !options?.closeHelperPane, + top: helperPaneState.top, + left: helperPaneState.left + }); + + // Trigger onChange to update parent + const serialized = customMarkdownSerializer.serialize(view.state.doc); + const newEditorValue = rawExpression ? rawExpression(serialized) : serialized; + onChange(newEditorValue, cursorPosition); + }; + + const fetchAndUpdateTokens = async (editorView: EditorView) => { + if (!rpcManager || !fileName) return; + if (!pendingTokenFetchRef.current) return; + + pendingTokenFetchRef.current = false; + + try { + const plainText = editorView.state.doc.textContent; + if (!plainText) return; + + const startLine = targetLineRange?.startLine; + + const wrappedForAPI = rawExpression ? rawExpression(plainText) : plainText; + + const tokens = await rpcManager.getExpressionTokens( + wrappedForAPI, + fileName, + startLine !== undefined ? startLine : undefined + ); + + updateChipTokens(editorView, { + tokens, + plainText, + wrappedText: wrappedForAPI + }); + } catch (error) { + console.error("Failed to fetch tokens:", error); + } + }; + + // Initialize ProseMirror editor + useEffect(() => { + if (!editorRef.current) return; + + const sanitizedValue = sanitizedExpression ? sanitizedExpression(value) : value; + const chipPlugin = createChipPlugin(chipSchema, handleChipClick); + + // Plugin to close helper pane when cursor moves + const cursorMovePlugin = new Plugin({ + view() { + return { + update(view, prevState) { + if (!view.state.doc.eq(prevState.doc)) { + return; + } + const oldSelection = prevState.selection; + const newSelection = view.state.selection; + + if (oldSelection.from !== newSelection.from || oldSelection.to !== newSelection.to) { + setHelperPaneState(prev => { + if (prev.isOpen) { + return { ...prev, isOpen: false }; + } + return prev; + }); + } + } + }; + } + }); + + const state = EditorState.create({ + doc: customMarkdownParser.parse(sanitizedValue), + schema: chipSchema, + plugins: [ + history(), + keymap({ + // Undo/Redo + "Mod-z": undo, + "Mod-y": redo, + "Mod-Shift-z": redo, + + // Text formatting + "Mod-b": toggleBold, + "Mod-i": toggleItalic, + // Mod-k removed: link insertion now requires dialog from toolbar + + // Headings + "Mod-Alt-1": toggleHeading(1), + "Mod-Alt-2": toggleHeading(2), + "Mod-Alt-3": toggleHeading(3), + "Mod-Alt-4": toggleHeading(4), + "Mod-Alt-5": toggleHeading(5), + "Mod-Alt-6": toggleHeading(6), + + // Block formatting + "Mod-Shift-9": toggleBlockquote, + "Mod-Shift-8": toggleBulletList, + "Mod-Shift-7": toggleOrderedList, + + // List management + "Enter": splitListItem(chipSchema.nodes.list_item), + "Mod-[": liftListItem(chipSchema.nodes.list_item), + "Mod-]": sinkListItem(chipSchema.nodes.list_item), + + // Helper pane + "Mod-/": () => handleKeyboardToggle(), + "Escape": () => { + if (helperPaneState.isOpen) { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + return true; + } + return false; + } + }), + keymap(baseKeymap), + gapCursor(), + chipPlugin, + cursorMovePlugin + ] + }); + + const view = new EditorView(editorRef.current, { + state, + dispatchTransaction(transaction) { + const newState = view.state.apply(transaction); + view.updateState(newState); + + // Check if we should fetch tokens based on what was typed + if ((transaction as any).docChanged) { + // Check if this is undo/redo + const meta = (transaction as any).getMeta('history$'); + if (meta) { + pendingTokenFetchRef.current = true; + fetchAndUpdateTokens(view); + } else { + // Check what text was inserted + let shouldTrigger = false; + (transaction as any).steps?.forEach((step: any) => { + if (step.slice?.content) { + const insertedText = step.slice.content.textBetween(0, step.slice.content.size); + if (insertedText.includes(' ') || insertedText.includes('}')) { + shouldTrigger = true; + } + } + }); + + if (shouldTrigger) { + pendingTokenFetchRef.current = true; + fetchAndUpdateTokens(view); + } + } + + // Call onChange when document changes + const serialized = customMarkdownSerializer.serialize(newState.doc); + const newValue = rawExpression ? rawExpression(serialized) : serialized; + const cursorPos = (newState.selection as any).$head?.pos || 0; + onChange(newValue, cursorPos); + } + }, + handleDOMEvents: { + keydown: (_view, event) => { + // Prevent Cmd+B from propagating to VSCode (which would open the sidebar) + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b' && !event.shiftKey && !event.altKey) { + event.stopPropagation(); + } + return false; + } + } + }); + + viewRef.current = view; + + if (onEditorViewReady) { + onEditorViewReady(view); + } + + // Fetch initial tokens + pendingTokenFetchRef.current = true; + fetchAndUpdateTokens(view); + + return () => { + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + }; + }, []); + + // Fetch tokens when value changes from parent + useEffect(() => { + if (viewRef.current) { + fetchAndUpdateTokens(viewRef.current); + } + }, [value]); + + // Handle click outside and escape key for helper pane + useHelperPaneClickOutside({ + enabled: helperPaneState.isOpen, + refs: { + editor: editorRef, + helperPane: helperPaneRef, + toggleButton: helperPaneToggleButtonRef, + toolbar: toolbarRef + }, + onClickOutside: () => { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + }, + onEscapeKey: () => { + setHelperPaneState(prev => ({ ...prev, isOpen: false })); + } + }); + + return ( + <> + + {helperPaneState.isOpen && getHelperPane && ( + + )} + + + ); +}; diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/chipPlugin.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/chipPlugin.ts new file mode 100644 index 00000000000..3943d0f8c58 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/chipPlugin.ts @@ -0,0 +1,427 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Schema, NodeSpec, DOMOutputSpec } from 'prosemirror-model'; +import { schema as markdownSchema } from 'prosemirror-markdown'; +import { + getParsedExpressionTokens, + detectTokenPatterns, + ParsedToken +} from '../ChipExpressionEditor/utils'; +import { + TokenType, + TokenMetadata, + CompoundTokenSequence +} from '../ChipExpressionEditor/types'; +import { + getTokenTypeColor, + getTokenIconClass, + getChipDisplayContent, + BASE_CHIP_STYLES, + BASE_ICON_STYLES, + CHIP_TEXT_STYLES +} from '../ChipExpressionEditor/chipStyles'; + +export const chipPluginKey = new PluginKey('chipPlugin'); + +/** + * Chip node specification - atomic inline node for variable/document chips + */ +const chipNodeSpec: NodeSpec = { + inline: true, + group: "inline", + atom: true, + selectable: false, + draggable: true, + attrs: { + tokenType: { default: TokenType.VARIABLE }, + text: { default: "" }, + displayText: { default: "" }, + start: { default: 0 }, + end: { default: 0 }, + metadata: { default: null }, + diagnostic: { default: null } + }, + toDOM: (node): DOMOutputSpec => { + const { tokenType, text, displayText, metadata, diagnostic } = node.attrs; + const chipElement = createChipElement( + displayText || text, + tokenType, + metadata, + diagnostic + ); + + return [ + 'span', + { + class: chipElement.className, + style: chipElement.style.cssText, + title: chipElement.title, + 'data-token-type': tokenType, + 'data-text': text + }, + // Add icon and text content + ...Array.from(chipElement.childNodes).map(child => { + if (child instanceof HTMLElement) { + return [ + child.tagName.toLowerCase(), + { + class: child.className, + style: child.style.cssText + }, + child.textContent || "" + ] as DOMOutputSpec; + } + return child.textContent || ""; + }) + ]; + }, + parseDOM: [{ + tag: "span.pm-chip", + getAttrs: (dom) => { + if (!(dom instanceof HTMLElement)) return false; + return { + tokenType: dom.getAttribute('data-token-type') || TokenType.VARIABLE, + text: dom.getAttribute('data-text') || '', + displayText: dom.textContent || '', + start: 0, + end: 0, + metadata: null, + diagnostic: null + }; + } + }] +}; + +export function createChipSchema(): Schema { + const nodes = markdownSchema.spec.nodes.addToEnd('chip', chipNodeSpec); + + return new Schema({ + nodes, + marks: markdownSchema.spec.marks + }); +} + +export const chipSchema = createChipSchema(); + +export interface TokenUpdate { + tokens: number[] | null; + plainText: string; // Plain text from doc.textContent (no markdown) + wrappedText: string; // Wrapped version sent to API (e.g., `text`) +} + +interface ChipPluginState { + tokenUpdate: TokenUpdate | null; + lastProcessedTokens: string | null; // Track processed tokens to avoid re-processing +} + +function createChipElement( + text: string, + type: TokenType, + metadata?: TokenMetadata, + diagnostic?: { severity: string; message: string } +): HTMLElement { + const span = document.createElement('span'); + span.className = 'pm-chip'; + + // Determine display text + let displayText = getChipDisplayContent(type, text); + if (type === TokenType.DOCUMENT) { + displayText = metadata?.content || text; + } + + // Get colors + const colors = getTokenTypeColor(type); + + // Apply base chip styles + Object.assign(span.style, { + ...BASE_CHIP_STYLES, + background: colors.background, + border: `1px solid ${colors.border}` + }); + + // Create icon element + const icon = document.createElement('i'); + const iconClass = getTokenIconClass(type, metadata?.documentType); + if (iconClass) { + icon.className = iconClass; + } + Object.assign(icon.style, { + ...BASE_ICON_STYLES, + color: colors.icon + }); + + // Create text span + const textSpan = document.createElement('span'); + textSpan.textContent = displayText; + const textStyles: any = { ...CHIP_TEXT_STYLES }; + + Object.assign(textSpan.style, textStyles); + + // Assemble chip + span.appendChild(icon); + span.appendChild(textSpan); + + return span; +} + +function findDocPosition(doc: any, textOffset: number): number { + // Clamp offset to valid range + if (textOffset <= 0) return 0; + if (textOffset >= doc.textContent.length) return doc.content.size; + + let charCount = 0; + let docPos = 0; + + doc.descendants((node: any, pos: number) => { + // If we've found the position, stop traversing + if (docPos > 0) return false; + + if (node.isText) { + const textLength = node.text.length; + + if (charCount + textLength >= textOffset) { + // This text node contains our target offset + docPos = pos + (textOffset - charCount); + return false; + } + + charCount += textLength; + } + + return true; + }); + + return docPos || 0; +} + +function replaceTextWithChips( + tr: any, + schema: Schema, + tokens: ParsedToken[], + compounds: CompoundTokenSequence[], + plainText: string +): any { + const docLength = plainText.length; + const replacements: Array<{ from: number; to: number; node: any }> = []; + + // Track which token indices are part of compounds + const compoundsByStartIndex = new Map(); + const compoundTokenIndices = new Set(); + + // Process compounds + for (const compound of compounds) { + // Validate compound range + if (compound.start < 0 || compound.end > docLength || compound.start >= compound.end) { + continue; + } + + // Group compounds by starting index + const existing = compoundsByStartIndex.get(compound.startIndex) || []; + existing.push(compound); + compoundsByStartIndex.set(compound.startIndex, existing); + + // Mark indices as consumed + for (let i = compound.startIndex; i <= compound.endIndex; i++) { + compoundTokenIndices.add(i); + } + } + + // Collect compound chip nodes + for (let i = 0; i < tokens.length; i++) { + const startingCompounds = compoundsByStartIndex.get(i); + if (startingCompounds) { + for (const compound of startingCompounds) { + if (compound.start < 0 || compound.end > docLength || compound.start >= compound.end) { + continue; + } + + const chipNode = schema.nodes.chip.create({ + tokenType: compound.tokenType, + text: plainText.slice(compound.start, compound.end), + displayText: compound.displayText, + start: compound.start, + end: compound.end, + metadata: compound.metadata, + diagnostic: null + }); + + const startDocPos = findDocPosition(tr.doc, compound.start); + const endDocPos = findDocPosition(tr.doc, compound.end); + + replacements.push({ from: startDocPos, to: endDocPos, node: chipNode }); + } + } + } + + // Collect individual token chip nodes + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + // Skip if token is part of compound + if (compoundTokenIndices.has(i)) { + continue; + } + + // Skip START_EVENT and END_EVENT tokens + if (token.type === TokenType.START_EVENT || token.type === TokenType.END_EVENT) { + continue; + } + + // Validate token range + if (token.start < 0 || token.end > docLength || token.start >= token.end) { + continue; + } + + const text = plainText.slice(token.start, token.end); + const chipNode = schema.nodes.chip.create({ + tokenType: token.type, + text, + displayText: getChipDisplayContent(token.type, text), + start: token.start, + end: token.end, + metadata: null, + diagnostic: null + }); + + const startDocPos = findDocPosition(tr.doc, token.start); + const endDocPos = findDocPosition(tr.doc, token.end); + + replacements.push({ from: startDocPos, to: endDocPos, node: chipNode }); + } + + // Sort replacements in reverse order (from end to start) to maintain positions + replacements.sort((a, b) => b.from - a.from); + + // Apply all replacements + for (const replacement of replacements) { + tr.replaceRangeWith(replacement.from, replacement.to, replacement.node); + } + + return tr; +} + +export function createChipPlugin( + schema: Schema, + onChipClick?: (event: MouseEvent, chipPos: number, chipNode: any) => void +) { + return new Plugin({ + key: chipPluginKey, + + state: { + init() { + return { tokenUpdate: null, lastProcessedTokens: null }; + }, + + apply(tr, value, _oldState, _newState) { + // Check if tokens have been updated via meta + const newTokenUpdate = tr.getMeta(chipPluginKey) as TokenUpdate | undefined; + + if (newTokenUpdate !== undefined) { + // Create a unique key for this token update to avoid reprocessing + const tokensKey = JSON.stringify({ + tokens: newTokenUpdate.tokens, + plainText: newTokenUpdate.plainText + }); + + // Only process if tokens have actually changed + if (tokensKey !== value.lastProcessedTokens) { + return { + tokenUpdate: newTokenUpdate, + lastProcessedTokens: tokensKey + }; + } + } + + return value; + } + }, + + // Process token updates and replace text with chip nodes + appendTransaction(_transactions, oldState, newState) { + const pluginState = this.getState(newState); + const oldPluginState = this.getState(oldState); + + // Check if we have new tokens to process + if (pluginState?.tokenUpdate && + pluginState.lastProcessedTokens !== oldPluginState?.lastProcessedTokens && + pluginState.tokenUpdate.tokens) { + + const { tokens: tokenStream, plainText, wrappedText } = pluginState.tokenUpdate; + + // Parse tokens + let parsedTokens = getParsedExpressionTokens(tokenStream, wrappedText); + + // Adjust token positions from wrapped to plain text + const prefixLength = wrappedText.indexOf(plainText); + if (prefixLength > 0) { + parsedTokens = parsedTokens.map(token => ({ + ...token, + start: Math.max(0, token.start - prefixLength), + end: Math.max(0, token.end - prefixLength) + })); + } + + // Detect compounds + const compounds = detectTokenPatterns(parsedTokens, plainText); + + // Create transaction to replace text with chips + const tr = newState.tr; + replaceTextWithChips(tr, schema, parsedTokens, compounds, plainText); + + // Mark this transaction as non-undoable since it's an automatic transformation + tr.setMeta('addToHistory', false); + + return tr; + } + + return null; + }, + + // Handle click events on chips + props: { + handleDOMEvents: { + click: (view, event) => { + if (!onChipClick) return false; + + const target = event.target as HTMLElement; + const chipElement = target.closest('.pm-chip'); + + if (chipElement) { + // Find the chip node at this position + const pos = view.posAtDOM(chipElement, 0); + const node = view.state.doc.nodeAt(pos); + + if (node && node.type.name === 'chip') { + onChipClick(event, pos, node); + return true; // Event handled + } + } + + return false; // Let other handlers process the event + } + } + } + }); +} + +export function updateChipTokens(view: any, tokenUpdate: TokenUpdate) { + const tr = view.state.tr.setMeta(chipPluginKey, tokenUpdate); + view.dispatch(tr); +} diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/markdownCommands.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/markdownCommands.ts new file mode 100644 index 00000000000..4b21d856e60 --- /dev/null +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/RichTextTemplateEditor/markdownCommands.ts @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EditorState, Transaction } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { toggleMark, setBlockType, wrapIn, lift } from "prosemirror-commands"; +import { wrapInList, liftListItem } from "prosemirror-schema-list"; +import { MarkType, NodeType } from "prosemirror-model"; +import { undo, redo } from "prosemirror-history"; + +export type Command = (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean; + +export const toggleBold: Command = (state, dispatch, view) => { + return toggleMark(state.schema.marks.strong)(state, dispatch, view); +}; + +export const toggleItalic: Command = (state, dispatch, view) => { + return toggleMark(state.schema.marks.em)(state, dispatch, view); +}; + +export const toggleCode: Command = (state, dispatch, view) => { + return toggleMark(state.schema.marks.code)(state, dispatch, view); +}; + +export const toggleLink = (href?: string, title?: string): Command => { + return (state, dispatch) => { + const linkMarkType = state.schema.marks.link; + const { from, to } = state.selection; + + // If there's a selection and it already has a link, remove it + if (from !== to && state.doc.rangeHasMark(from, to, linkMarkType)) { + if (dispatch) { + dispatch((state.tr as any).removeMark(from, to, linkMarkType)); + } + return true; + } + + // Can't add a link without an href + if (!href) return false; + + if (dispatch) { + const mark = linkMarkType.create({ href }); + const tr = state.tr; + + if (from === to) { + // No selection: insert new text with link + const linkText = title || href; + const textNode = state.schema.text(linkText, [mark]); + tr.replaceSelectionWith(textNode, false); + } else { + // Has selection: wrap selection with link + (tr as any).addMark(from, to, mark); + } + + dispatch(tr); + } + return true; + }; +}; + +export const toggleHeading = (level: number): Command => { + return (state, dispatch, view) => { + const { schema } = state; + const isActive = isNodeActive(state, schema.nodes.heading, { level }); + + if (isActive) { + return setBlockType(schema.nodes.paragraph)(state, dispatch, view); + } + return setBlockType(schema.nodes.heading, { level })(state, dispatch, view); + }; +}; + +export const setParagraph: Command = (state, dispatch, view) => { + return setBlockType(state.schema.nodes.paragraph)(state, dispatch, view); +}; + +export const toggleBlockquote: Command = (state, dispatch, view) => { + const blockquote = state.schema.nodes.blockquote; + if (isNodeActive(state, blockquote)) { + return lift(state, dispatch); + } + return wrapIn(blockquote)(state, dispatch, view); +}; + +const convertListType = (state: EditorState, dispatch: ((tr: Transaction) => void) | undefined, fromListType: NodeType, toListType: NodeType): boolean => { + if (!dispatch) return true; + + const { $from } = state.selection; + for (let d = $from.depth; d >= 0; d--) { + if ($from.node(d).type === fromListType) { + const pos = $from.before(d); + const node = $from.node(d); + const tr = (state.tr as any).replaceWith(pos, pos + node.nodeSize, toListType.create(node.attrs, node.content)); + dispatch(tr.setSelection(state.selection.map(tr.doc, tr.mapping))); + return true; + } + } + return false; +}; + +export const toggleBulletList: Command = (state, dispatch, view) => { + const { bullet_list, ordered_list, list_item } = state.schema.nodes; + + if (isListActive(state, bullet_list)) { + return liftListItem(list_item)(state, dispatch, view); + } + + if (isListActive(state, ordered_list)) { + return convertListType(state, dispatch, ordered_list, bullet_list); + } + + return wrapInList(bullet_list)(state, dispatch, view); +}; + +export const toggleOrderedList: Command = (state, dispatch, view) => { + const { bullet_list, ordered_list, list_item } = state.schema.nodes; + + if (isListActive(state, ordered_list)) { + return liftListItem(list_item)(state, dispatch, view); + } + + if (isListActive(state, bullet_list)) { + return convertListType(state, dispatch, bullet_list, ordered_list); + } + + return wrapInList(ordered_list)(state, dispatch, view); +}; + +export const isMarkActive = (state: EditorState, type: MarkType): boolean => { + const { from, to, $from, empty } = state.selection; + if (empty) { + return !!type.isInSet(state.storedMarks || $from.marks()); + } + return state.doc.rangeHasMark(from, to, type); +}; + +export const isNodeActive = (state: EditorState, type: NodeType, attrs?: Record): boolean => { + const { $from } = state.selection; + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + if (node.type === type) { + if (!attrs) return true; + return Object.keys(attrs).every(key => node.attrs[key] === attrs[key]); + } + } + return false; +}; + +export const isListActive = (state: EditorState, listType: NodeType): boolean => { + const { $from } = state.selection; + for (let d = $from.depth; d >= 0; d--) { + const node = $from.node(d); + if (node.type.name === 'list_item') { + if (d > 0) { + const parent = $from.node(d - 1); + if (parent.type === listType) { + return true; + } + } + } + } + return false; +}; + +export const undoCommand: Command = (state, dispatch) => { + return undo(state, dispatch); +}; + +export const redoCommand: Command = (state, dispatch) => { + return redo(state, dispatch); +}; + +export const canUndo = (state: EditorState): boolean => { + return undo(state); +}; + +export const canRedo = (state: EditorState): boolean => { + return redo(state); +}; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx index 54a40f33396..a8a5f2f01b4 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Views/DocumentConfig.tsx @@ -158,7 +158,7 @@ export const DocumentConfig = ({ onChange, onClose, targetLineRange, filteredCom const needsTypeCasting = typeInfo.includes("string") || typeInfo.includes("byte[]") || typeInfo.includes("ai:Url"); // Check if we're in template mode - const isTemplateMode = inputMode === InputMode.TEMPLATE; + const isTemplateMode = inputMode === InputMode.PROMPT || inputMode === InputMode.TEMPLATE; if (isAIDocumentType) { // For AI document types, wrap in string interpolation only in template mode @@ -210,8 +210,8 @@ export const DocumentConfig = ({ onChange, onClose, targetLineRange, filteredCom if (!url.trim()) { return; } - const isTemplateMode = inputMode === InputMode.TEMPLATE; - const wrappedValue = wrapInDocumentType(documentType, `"${url.trim()}"`); + const isTemplateMode = inputMode === InputMode.PROMPT; + const wrappedValue = wrapInDocumentType(documentType, `"${url.trim()}"`, isTemplateMode); onChange(wrappedValue, false, false); closeModal(POPUP_IDS.DOCUMENT_URL); }; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts index 0cbe367348d..60d4d694fe8 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/utils/utils.ts @@ -19,6 +19,6 @@ import { InputMode } from "@wso2/ballerina-side-panel"; // Wraps a value in template interpolation syntax ${} if in template mode -export const wrapInTemplateInterpolation = (value: string, _inputMode?: InputMode): string => { - return value; +export const wrapInTemplateInterpolation = (value: string, inputMode?: InputMode): string => { + return (inputMode === InputMode.PROMPT || inputMode === InputMode.TEMPLATE) ? `\${${value}}` : value; }; diff --git a/workspaces/common-libs/font-wso2-vscode/src/icons/bi-arrow-down.svg b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-arrow-down.svg new file mode 100644 index 00000000000..a1181022246 --- /dev/null +++ b/workspaces/common-libs/font-wso2-vscode/src/icons/bi-arrow-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From db885f2f582fa691372b96b49310cdeb0b2befde Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Sat, 29 Nov 2025 18:55:50 +0530 Subject: [PATCH 02/12] Add support for expanded prompt editor in natural functions --- common/config/rush/pnpm-lock.yaml | 3 + .../components/ChipExpressionEditor.tsx | 73 +++- .../src/components/editors/index.ts | 3 +- .../src/views/BI/FocusFlowDiagram/index.tsx | 74 +++- .../src/views/BI/FocusFlowDiagram/utils.tsx | 144 ++++++ .../Components/EmptyItemsPlaceHolder.tsx | 2 +- workspaces/ballerina/bi-diagram/package.json | 1 + .../bi-diagram/src/components/Diagram.tsx | 7 +- .../src/components/DiagramContext.tsx | 29 +- .../src/components/editors/NPPromptEditor.tsx | 413 ++++++++++++++++++ .../nodes/PromptNode/PromptNodeWidget.tsx | 152 +++---- workspaces/ballerina/bi-diagram/src/index.tsx | 1 + 12 files changed, 763 insertions(+), 139 deletions(-) create mode 100644 workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/utils.tsx create mode 100644 workspaces/ballerina/bi-diagram/src/components/editors/NPPromptEditor.tsx diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7b06974f807..d26f1e94b98 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1325,6 +1325,9 @@ importers: '@wso2/ballerina-core': specifier: workspace:* version: link:../ballerina-core + '@wso2/ballerina-side-panel': + specifier: workspace:* + version: link:../ballerina-side-panel '@wso2/ui-toolkit': specifier: workspace:* version: link:../../common-libs/ui-toolkit diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx index b72327252ca..8bcb54afb8c 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor.tsx @@ -17,7 +17,7 @@ */ import { EditorState } from "@codemirror/state"; -import { EditorView, hoverTooltip, keymap, tooltips } from "@codemirror/view"; +import { EditorView, keymap, tooltips, placeholder, hoverTooltip } from "@codemirror/view"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useFormContext } from "../../../../../context"; import { @@ -98,6 +98,9 @@ export type ChipExpressionEditorComponentProps = { enableListContinuation?: boolean; configuration?: ChipExpressionEditorDefaultConfiguration; inputMode?: InputMode; + hideFxButton?: boolean; + disabled?: boolean; + placeholder?: string; } export const ChipExpressionEditorComponent = (props: ChipExpressionEditorComponentProps) => { @@ -148,6 +151,14 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone const tooltipExtension = hoverTooltip((view, pos) => { return docTooltip(view, pos); }); + // Create a stable compartment for the editable configuration + const editableCompartment = useMemo(() => new Compartment(), []); + + // Memoize the getCursorCoords function to avoid unnecessary re-renders + const getCursorCoords = React.useCallback(() => { + return viewRef.current?.coordsAtPos(viewRef.current.state.selection.main.head) || null; + }, []); + // Helper pane state management with conditional fixed placement for toolbar toggle in PROMPT mode const { helperPaneState, setHelperPaneState, handleManualToggle, handleKeyboardToggle } = useHelperPane( { @@ -165,7 +176,7 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone }); } : undefined }, - () => viewRef.current?.coordsAtPos(viewRef.current.state.selection.main.head) || null + getCursorCoords ); const needTokenRefetchListner = buildNeedTokenRefetchListner(() => { @@ -350,27 +361,33 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone activateOnTyping: true, closeOnBlur: true }), + ...(props.placeholder ? [placeholder(props.placeholder)] : []), tooltips({ position: "absolute" }), chipPlugin, tokenField, chipTheme, completionTheme, EditorView.lineWrapping, + editableCompartment.of(EditorView.editable.of(!props.disabled)), needTokenRefetchListner, handleChangeListner, handleFocusListner, handleFocusOutListner, tooltipExtension, handleSelectionChange, - ...(props.isInExpandedMode + ...(props.isInExpandedMode || props.hideFxButton ? [EditorView.theme({ "&": { height: "100%" }, ".cm-scroller": { overflow: "auto", maxHeight: "100%" } })] - : props.sx && 'height' in props.sx + : props.sx && 'height' in props.sx && props.sx.height ? [EditorView.theme({ - "&": { height: "100%" }, - ".cm-scroller": { overflow: "auto", maxHeight: "100%" } + "&": { + height: typeof props.sx.height === 'number' ? + `${props.sx.height}px` : + props.sx.height + }, + ".cm-scroller": { overflow: "auto" } })] : [EditorView.theme({ "&": { maxHeight: "150px" }, @@ -452,6 +469,14 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone setIsTokenUpdateScheduled(true); }, [Boolean(props.sanitizedExpression), Boolean(props.rawExpression)]); + // Update editor editable state when disabled prop changes + useEffect(() => { + if (!viewRef.current) return; + viewRef.current.dispatch({ + effects: editableCompartment.reconfigure(EditorView.editable.of(!props.disabled)) + }); + }, [props.disabled, editableCompartment]); + // Handle click outside and escape key for helper pane useHelperPaneClickOutside({ enabled: helperPaneState.isOpen, @@ -485,13 +510,13 @@ export const ChipExpressionEditorComponent = (props: ChipExpressionEditorCompone {!props.isInExpandedMode && configuration.getAdornment()({ onClick: () => {}})}
} - - {!props.isExpandedVersion && - - {helperPaneState.isOpen ? : } - } - {props.onOpenExpandedMode && ( - - {props.isInExpandedMode ? : } - - )} - + {!props.disabled && ( + + {!props.isExpandedVersion && + + {helperPaneState.isOpen ? : } + } + {props.onOpenExpandedMode && ( + + {props.isInExpandedMode ? : } + + )} + + )}
diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts b/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts index a541f9d82c8..722d68bda9b 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/index.ts @@ -28,4 +28,5 @@ export * from "./FormMapEditor"; export * from "./FieldContext"; export * from "./MultiModeExpressionEditor/ChipExpressionEditor/components/ChipExpressionEditor"; export { getPropertyFromFormField } from "./utils"; -export { InputMode } from "./MultiModeExpressionEditor/ChipExpressionEditor/types"; \ No newline at end of file +export { InputMode } from "./MultiModeExpressionEditor/ChipExpressionEditor/types"; +export { ExpandedEditor } from "./ExpandedEditor"; diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/index.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/index.tsx index a10faa2f9e8..9c865d3f87c 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/index.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/index.tsx @@ -19,11 +19,9 @@ import { debounce } from "lodash"; import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useRpcContext } from "@wso2/ballerina-rpc-client"; -import { - Category as PanelCategory, -} from "@wso2/ballerina-side-panel"; +import { Category as PanelCategory } from "@wso2/ballerina-side-panel"; import styled from "@emotion/styled"; -import { MemoizedDiagram } from "@wso2/bi-diagram"; +import { MemoizedDiagram, GetHelperPaneFunction } from "@wso2/bi-diagram"; import { BIAvailableNodesRequest, Flow, @@ -57,10 +55,11 @@ import { updateLineRange, } from "../../../utils/bi"; import { getNodeTemplateForConnection } from "../FlowDiagram/utils"; -import { View, ProgressRing, ProgressIndicator, ThemeColors, CompletionItem } from "@wso2/ui-toolkit"; +import { View, ProgressRing, ProgressIndicator, ThemeColors, CompletionItem, FormExpressionEditorRef, HelperPaneHeight } from "@wso2/ui-toolkit"; import { EXPRESSION_EXTRACTION_REGEX } from "../../../constants"; import { ConnectionKind } from "../../../components/ConnectionSelector"; import { SidePanelView } from "../FlowDiagram/PanelManager"; +import { createPromptHelperPane } from "./utils"; const Container = styled.div` width: 100%; @@ -431,7 +430,6 @@ export function BIFocusFlowDiagram(props: BIFocusFlowDiagramProps) { value: string, property: ExpressionProperty, offset: number, - invalidateCache: boolean, triggerCharacter?: string ) => { let expressionCompletions: CompletionItem[] = []; @@ -441,8 +439,7 @@ export function BIFocusFlowDiagram(props: BIFocusFlowDiagramProps) { if ( completions.length > 0 && !triggerCharacter && - parentContent === prevCompletionFetchText.current && - !invalidateCache + parentContent === prevCompletionFetchText.current ) { expressionCompletions = completions .filter((completion) => { @@ -511,10 +508,9 @@ export function BIFocusFlowDiagram(props: BIFocusFlowDiagramProps) { value: string, property: ExpressionProperty, offset: number, - invalidateCache: boolean, triggerCharacter?: string ) => { - await debouncedRetrieveCompletions(value, property, offset, invalidateCache, triggerCharacter); + await debouncedRetrieveCompletions(value, property, offset, triggerCharacter); if (triggerCharacter) { await debouncedRetrieveCompletions.flush(); @@ -535,6 +531,18 @@ export function BIFocusFlowDiagram(props: BIFocusFlowDiagramProps) { handleExpressionEditorCancel(); }; + const handleGetExpressionTokens = async ( + expression: string, + fileName: string, + position: { line: number; offset: number } + ): Promise => { + return rpcClient.getBIDiagramRpcClient().getExpressionTokens({ + expression: expression, + filePath: fileName, + position: position + }); + }; + const handleExpressionEditorBlur = () => { handleExpressionEditorCancel(); }; @@ -593,6 +601,46 @@ export function BIFocusFlowDiagram(props: BIFocusFlowDiagramProps) { setShowProgressIndicator(false); }; + const createHelperPane = useCallback(( + fieldKey, + exprRef, + anchorRef, + defaultValue, + value, + onChange, + changeHelperPaneState, + helperPaneHeight, + recordTypeField, + isAssignIdentifier, + valueTypeConstraint, + inputMode + ) => { + if (!selectedNode || !model) { + return <>; + } + + return createPromptHelperPane({ + selectedNode, + model, + fieldKey, + exprRef, + anchorRef, + defaultValue, + value, + onChange, + changeHelperPaneState, + helperPaneHeight, + recordTypeField, + valueTypeConstraint, + inputMode, + completions, + filteredCompletions, + projectPath, + rpcClient, + debouncedRetrieveCompletions + }); + }, [model, projectPath, completions, filteredCompletions, debouncedRetrieveCompletions, rpcClient, selectedNode]); + const memoizedDiagramProps = useMemo( () => ({ model: flowModel, @@ -611,13 +659,15 @@ export function BIFocusFlowDiagram(props: BIFocusFlowDiagramProps) { retrieveCompletions: handleRetrieveCompletions, onCompletionItemSelect: handleCompletionItemSelect, onBlur: handleExpressionEditorBlur, - onCancel: handleExpressionEditorCancel + onCancel: handleExpressionEditorCancel, + getHelperPane: createHelperPane, + getExpressionTokens: handleGetExpressionTokens }, aiNodes: { onModelSelect: handleOnEditNPFunctionModel, }, }), - [flowModel, projectPath, breakpointInfo, filteredCompletions] + [flowModel, projectPath, breakpointInfo, filteredCompletions, createHelperPane, handleGetExpressionTokens] ); return ( diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/utils.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/utils.tsx new file mode 100644 index 00000000000..a7b6e069b95 --- /dev/null +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/FocusFlowDiagram/utils.tsx @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RefObject } from "react"; +import { FieldProvider, FormField, InputMode } from "@wso2/ballerina-side-panel"; +import { + Flow, + FunctionNode, + LineRange, + Property, + RecordTypeField, + TRIGGER_CHARACTERS +} from "@wso2/ballerina-core"; +import { CompletionItem, FormExpressionEditorRef, HelperPaneHeight } from "@wso2/ui-toolkit"; +import { getHelperPaneNew } from "../HelperPaneNew"; +import { BallerinaRpcClient } from "@wso2/ballerina-rpc-client"; + +interface CreatePromptHelperPaneParams { + selectedNode: FunctionNode; + model: Flow; + fieldKey: string; + exprRef: RefObject; + anchorRef: RefObject; + defaultValue: string; + value: string; + onChange: (value: string, options?: any) => void; + changeHelperPaneState: (isOpen: boolean) => void; + helperPaneHeight: HelperPaneHeight; + recordTypeField?: RecordTypeField; + valueTypeConstraint?: string | string[]; + inputMode?: InputMode; + completions: CompletionItem[]; + filteredCompletions: CompletionItem[]; + projectPath: string; + rpcClient: BallerinaRpcClient; + debouncedRetrieveCompletions: ( + value: string, + property: Property, + offset: number, + triggerCharacter?: string + ) => Promise; +} + +export function createPromptHelperPane(params: CreatePromptHelperPaneParams): JSX.Element { + const { + selectedNode, + model, + fieldKey, + exprRef, + anchorRef, + defaultValue, + value, + onChange, + changeHelperPaneState, + helperPaneHeight, + recordTypeField, + valueTypeConstraint, + inputMode, + completions, + filteredCompletions, + projectPath, + rpcClient, + debouncedRetrieveCompletions + } = params; + + const property: Property = { + metadata: { label: "Prompt", description: "Prompt expression" }, + valueType: "ai:Prompt", + value: value, + optional: false, + editable: true + }; + + const finalRecordTypeField: RecordTypeField = recordTypeField || { + key: fieldKey, + property: property, + recordTypeMembers: [] + }; + + const field: FormField = { + codedata: selectedNode.properties?.['prompt'].codedata, + key: fieldKey, + label: "Prompt", + type: "RAW_TEMPLATE", + valueTypeConstraint: valueTypeConstraint, + enabled: true, + optional: false, + editable: true, + documentation: "Prompt expression", + value: value, + valueType: "RAW_TEMPLATE" + }; + + const helperPane = getHelperPaneNew({ + fieldKey: fieldKey, + fileName: model.fileName, + targetLineRange: selectedNode.codedata?.lineRange || (selectedNode.properties?.['prompt']?.codedata?.lineRange as LineRange), + anchorRef: anchorRef, + onClose: () => changeHelperPaneState(false), + defaultValue: defaultValue, + currentValue: value, + onChange: (newValue: string) => onChange(newValue, undefined), + helperPaneHeight: helperPaneHeight, + recordTypeField: finalRecordTypeField, + updateImports: async (importStatement: string) => { + await rpcClient.getBIDiagramRpcClient().updateImports({ + filePath: model.fileName, + importStatement: importStatement, + }); + }, + completions: completions, + projectPath: projectPath, + selectedType: undefined, + filteredCompletions: filteredCompletions, + isInModal: false, + valueTypeConstraint: valueTypeConstraint as string || "ai:Prompt", + forcedValueTypeConstraint: valueTypeConstraint as string || "ai:Prompt", + handleRetrieveCompletions: async (value: string, property: Property, offset: number, triggerCharacter?: string) => + await debouncedRetrieveCompletions(value, property, offset, triggerCharacter), + handleValueTypeConstChange: () => { }, + inputMode: inputMode || InputMode.PROMPT + }); + + return ( + + {helperPane} + + ); +} diff --git a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Components/EmptyItemsPlaceHolder.tsx b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Components/EmptyItemsPlaceHolder.tsx index 61600101b94..44b1b6e05d9 100644 --- a/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Components/EmptyItemsPlaceHolder.tsx +++ b/workspaces/ballerina/ballerina-visualizer/src/views/BI/HelperPaneNew/Components/EmptyItemsPlaceHolder.tsx @@ -29,7 +29,7 @@ export const EmptyItemsPlaceHolder = ({ message = "No items found" }: EmptyItems height: '100%', width: '100%' }}> - {message} + {message}
) } diff --git a/workspaces/ballerina/bi-diagram/package.json b/workspaces/ballerina/bi-diagram/package.json index e66085cab4a..667a338db30 100644 --- a/workspaces/ballerina/bi-diagram/package.json +++ b/workspaces/ballerina/bi-diagram/package.json @@ -34,6 +34,7 @@ "@projectstorm/react-diagrams-routing": "^7.1.3", "@wso2/ui-toolkit": "workspace:*", "@wso2/ballerina-core": "workspace:*", + "@wso2/ballerina-side-panel": "workspace:*", "@emotion/react": "^11.9.3", "@emotion/styled": "^11.10.5", "dagre": "~0.8.5", diff --git a/workspaces/ballerina/bi-diagram/src/components/Diagram.tsx b/workspaces/ballerina/bi-diagram/src/components/Diagram.tsx index 23729af54c3..1b99d004d11 100644 --- a/workspaces/ballerina/bi-diagram/src/components/Diagram.tsx +++ b/workspaces/ballerina/bi-diagram/src/components/Diagram.tsx @@ -328,7 +328,12 @@ export function Diagram(props: DiagramProps) { project: project, readOnly: onAddNode === undefined || onDeleteNode === undefined || onNodeSelect === undefined || readOnly, isUserAuthenticated: isUserAuthenticated, - expressionContext: expressionContext, + expressionContext: expressionContext || { + completions: [], + triggerCharacters: [], + retrieveCompletions: () => Promise.resolve(), + getHelperPane: undefined, + }, }; const getActiveBreakpointNode = (nodes: NodeModel[]): NodeModel => { diff --git a/workspaces/ballerina/bi-diagram/src/components/DiagramContext.tsx b/workspaces/ballerina/bi-diagram/src/components/DiagramContext.tsx index 1516c14abf8..8fed468bab4 100644 --- a/workspaces/ballerina/bi-diagram/src/components/DiagramContext.tsx +++ b/workspaces/ballerina/bi-diagram/src/components/DiagramContext.tsx @@ -16,10 +16,11 @@ * under the License. */ -import React, { useState } from "react"; +import React, { useState, RefObject } from "react"; import { Flow, FlowNode, Branch, LineRange, NodePosition, ToolData } from "../utils/types"; -import { CompletionItem } from "@wso2/ui-toolkit"; -import { ExpressionProperty, JoinProjectPathRequest, JoinProjectPathResponse, TextEdit, VisualizerLocation } from "@wso2/ballerina-core"; +import { CompletionItem, FormExpressionEditorRef, HelperPaneHeight } from "@wso2/ui-toolkit"; +import { ExpressionProperty, JoinProjectPathRequest, JoinProjectPathResponse, RecordTypeField, TextEdit, VisualizerLocation } from "@wso2/ballerina-core"; +import { HelperpaneOnChangeOptions, InputMode } from "@wso2/ballerina-side-panel"; type CompletionConditionalProps = { completions: CompletionItem[]; @@ -28,7 +29,6 @@ type CompletionConditionalProps = { value: string, property: ExpressionProperty, offset: number, - invalidateCache: boolean, triggerCharacter?: string ) => Promise; } | { @@ -37,11 +37,32 @@ type CompletionConditionalProps = { retrieveCompletions?: never; } +export type GetHelperPaneFunction = ( + fieldKey: string, + exprRef: RefObject, + anchorRef: RefObject, + defaultValue: string, + value: string, + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, + changeHelperPaneState: (isOpen: boolean) => void, + helperPaneHeight: HelperPaneHeight, + recordTypeField?: RecordTypeField, + isAssignIdentifier?: boolean, + valueTypeConstraint?: string | string[], + inputMode?: InputMode +) => JSX.Element; + export type ExpressionContextProps = CompletionConditionalProps & { onCompletionItemSelect?: (value: string, additionalTextEdits?: TextEdit[]) => Promise; onFocus?: () => void | Promise; onBlur?: () => void | Promise; onCancel?: () => void; + getHelperPane?: GetHelperPaneFunction; + getExpressionTokens?: ( + expression: string, + filePath: string, + position: { line: number; offset: number } + ) => Promise; } export interface DiagramContextState { diff --git a/workspaces/ballerina/bi-diagram/src/components/editors/NPPromptEditor.tsx b/workspaces/ballerina/bi-diagram/src/components/editors/NPPromptEditor.tsx new file mode 100644 index 00000000000..5ad1cc8fff8 --- /dev/null +++ b/workspaces/ballerina/bi-diagram/src/components/editors/NPPromptEditor.tsx @@ -0,0 +1,413 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useCallback, useRef, useState, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { debounce } from "lodash"; +import styled from "@emotion/styled"; +import { + ChipExpressionEditorComponent, + Context as FormContext, + FormExpressionEditorProps, + HelperpaneOnChangeOptions, + InputMode, + ExpandedEditor +} from "@wso2/ballerina-side-panel"; +import { + CompletionItem, + FormExpressionEditorRef, + HelperPaneHeight, + FnSignatureDocumentation, + ErrorBanner +} from "@wso2/ui-toolkit"; +import { + ExpressionProperty, + FlowNode, + LineRange, + NodeKind, + TextEdit +} from "@wso2/ballerina-core"; +import { GetHelperPaneFunction } from "../DiagramContext"; + +export interface NPPromptEditorProps { + node: FlowNode; + fileName: string; + targetLineRange: LineRange; + value: string; + onChange: (value: string, cursorPosition: number) => void; + placeholder?: string; + completions: CompletionItem[]; + triggerCharacters: readonly string[]; + retrieveCompletions: ( + value: string, + property: ExpressionProperty, + offset: number, + triggerCharacter?: string + ) => Promise; + onCompletionItemSelect?: (value: string, additionalTextEdits?: TextEdit[]) => Promise; + onFocus?: () => void | Promise; + onBlur?: () => void | Promise; + onCancel?: () => void; + extractArgsFromFunction?: ( + value: string, + property: ExpressionProperty, + cursorPosition: number + ) => Promise<{ + label: string; + args: string[]; + currentArgIndex: number; + documentation?: FnSignatureDocumentation; + }>; + getHelperPane?: GetHelperPaneFunction; + getExpressionTokens?: ( + expression: string, + filePath: string, + position: { line: number; offset: number } + ) => Promise; + sx?: React.CSSProperties; + inputMode?: InputMode; + // Diagnostics support + enableDiagnostics?: boolean; + getExpressionFormDiagnostics?: ( + showDiagnostics: boolean, + expression: string, + key: string, + property: ExpressionProperty, + setDiagnosticsInfo: (diagnostics: { key: string; diagnostics: any[] }) => void, + shouldUpdateNode?: boolean, + variableType?: string + ) => Promise; + disabled?: boolean; +} + +const EditorContainer = styled.div<{ disabled: boolean }>` + width: 100%; + height: ${props => props.disabled ? '15rem' : '13rem'}; + margin-top: 0.5rem; + cursor: ${props => props.disabled ? 'not-allowed' : 'text'}; + + .cm-editor { + background-color: ${props => props.disabled ? 'var(--vscode-editor-background)' : 'var(--vscode-input-background)'}; + transition: background-color 0.2s ease-in-out; + } +`; + +export const NPPromptEditor: React.FC = (props) => { + const { + fileName, + targetLineRange, + value, + onChange, + placeholder, + completions, + triggerCharacters, + retrieveCompletions, + onCompletionItemSelect, + onFocus, + onBlur, + onCancel, + extractArgsFromFunction, + getHelperPane, + getExpressionTokens, + sx, + inputMode = InputMode.EXP, + enableDiagnostics = false, + getExpressionFormDiagnostics, + disabled = false + } = props; + + // Form state for ChipExpressionEditor + const { control, watch, setValue, getValues, register, unregister, setError, clearErrors, formState } = useForm(); + + // Diagnostics state + const [formDiagnostics, setFormDiagnostics] = useState([]); + + // Expanded mode state + const [isExpandedModalOpen, setIsExpandedModalOpen] = useState(false); + + // Refs + const exprRef = useRef(null); + const anchorRef = useRef(null); + + // Backtick handling functions + const getSanitizedExp = (rawValue: string): string => { + if (!rawValue) return rawValue; + if (rawValue.startsWith("`") && rawValue.endsWith("`")) { + return rawValue.slice(1, -1); + } + return rawValue; + }; + + const getRawExp = (sanitizedValue: string): string => { + if (!sanitizedValue) return sanitizedValue; + if (!sanitizedValue.startsWith("`") && !sanitizedValue.endsWith("`")) { + return `\`${sanitizedValue}\``; + } + return sanitizedValue; + }; + + // Helper to create ExpressionProperty + const createProperty = (expressionValue: string): ExpressionProperty => { + const promptProperty = props.node.properties['prompt']; + return { + ...promptProperty, + value: getSanitizedExp(expressionValue), + } + }; + + // Debounced diagnostics fetching + const fetchDiagnostics = useCallback( + debounce(async (expression: string) => { + if (!enableDiagnostics || !getExpressionFormDiagnostics) { + return; + } + + const property = createProperty(expression); + + const handleSetDiagnosticsInfo = (diagnosticsInfo: { key: string; diagnostics: any[] }) => { + const diagnostics = diagnosticsInfo?.diagnostics || []; + setFormDiagnostics(diagnostics); + }; + + try { + await getExpressionFormDiagnostics( + expression !== '', + expression, + "expression", + property, + handleSetDiagnosticsInfo, + false, + undefined + ); + } catch (error) { + console.error('Failed to fetch diagnostics:', error); + setFormDiagnostics([]); + } + }, 300), + [enableDiagnostics, getExpressionFormDiagnostics] + ); + + // Handle change with diagnostics + const handleChange = (updatedValue: string, updatedCursorPosition: number) => { + const sanitized = getSanitizedExp(updatedValue); + onChange(sanitized, updatedCursorPosition); + + if (enableDiagnostics) { + fetchDiagnostics(sanitized); + } + }; + + // Expanded mode handlers + const handleOpenExpandedMode = () => { + setIsExpandedModalOpen(true); + }; + + const handleCloseExpandedMode = () => { + setIsExpandedModalOpen(false); + }; + + const handleSaveExpandedMode = (newValue: string) => { + const sanitized = getSanitizedExp(newValue); + onChange(sanitized, 0); + setIsExpandedModalOpen(false); + }; + + const handleChangeFromExpandedEditor = async (updatedValue: string, cursorPosition: number) => { + const sanitized = getSanitizedExp(updatedValue); + onChange(sanitized, cursorPosition); + + // Trigger completions with raw value + const property = createProperty(updatedValue); + const triggerCharacter = cursorPosition > 0 + ? triggerCharacters.find(char => updatedValue[cursorPosition - 1] === char) + : undefined; + + try { + await retrieveCompletions( + updatedValue, + property, + cursorPosition + 1, + triggerCharacter + ); + } catch (error) { + console.error('Failed to retrieve completions:', error); + } + }; + + // Adapter: ChipExpressionEditor expects (value, onChange, height) but GetHelperPaneFunction has 12 params + const wrappedGetHelperPane = useMemo(() => { + if (!getHelperPane) return undefined; + + return ( + value: string, + onChange: (value: string, options?: HelperpaneOnChangeOptions) => void, + helperPaneHeight: HelperPaneHeight + ) => getHelperPane( + "prompt", + exprRef, + anchorRef, + "", + getSanitizedExp(value), + onChange, + () => { }, // Helper pane state managed by ChipExpressionEditor + helperPaneHeight, + undefined, + undefined, + undefined, + inputMode + ); + }, [getHelperPane, inputMode]); + + // Adapter: provides ExpressionProperty context to extractArgsFromFunction + const chipExtractArgsFromFunction = useMemo(() => { + if (!extractArgsFromFunction) return undefined; + + return async (value: string, cursorPosition: number) => { + const sanitizedValue = getSanitizedExp(value); + const property = createProperty(sanitizedValue); + return await extractArgsFromFunction(sanitizedValue, property, cursorPosition); + }; + }, [extractArgsFromFunction]); + + // Create FormContext value + const expressionEditor: FormExpressionEditorProps = { + completions: completions, + triggerCharacters: triggerCharacters, + retrieveCompletions: async (value: string, property: ExpressionProperty, offset: number, triggerCharacter?: string) => { + await retrieveCompletions(getSanitizedExp(value), property, offset, triggerCharacter); + }, + extractArgsFromFunction: extractArgsFromFunction, + ...(getHelperPane ? { + getHelperPane: getHelperPane, + helperPaneOrigin: "vertical" as const, + helperPaneHeight: "default" as const, + } : {}), + onCompletionItemSelect: async (value: string, fieldKey: string, additionalTextEdits?: TextEdit[]) => { + if (onCompletionItemSelect) { + await onCompletionItemSelect(getSanitizedExp(value), additionalTextEdits); + } + }, + onBlur: onBlur, + onCancel: onCancel, + getExpressionFormDiagnostics: getExpressionFormDiagnostics, + rpcManager: { + getExpressionTokens: getExpressionTokens || (async () => []) + } + } as FormExpressionEditorProps; + + const formContextValue = useMemo(() => ({ + form: { + control, + watch, + setValue, + getValues, + register, + unregister, + setError, + clearErrors, + formState + }, + expressionEditor: expressionEditor, + targetLineRange: targetLineRange, + fileName: fileName, + popupManager: { + addPopup: () => { }, + removeLastPopup: () => { }, + closePopup: () => { } + }, + nodeInfo: { + kind: "NP_FUNCTION" as NodeKind + } + }), [ + control, watch, setValue, getValues, register, unregister, + setError, clearErrors, formState, expressionEditor, + targetLineRange, fileName + ]); + + // Prevent scroll and mouse events from propagating to parent diagram + const handleWheel = useCallback((e: React.WheelEvent) => { + e.stopPropagation(); + }, []); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + return ( + +
+ + + {enableDiagnostics && formDiagnostics && formDiagnostics.length > 0 && ( + d.message).join(', ')} /> + )} + + + {/* Expanded Editor Modal */} + {isExpandedModalOpen && ( + + )} +
+
+ ); +}; diff --git a/workspaces/ballerina/bi-diagram/src/components/nodes/PromptNode/PromptNodeWidget.tsx b/workspaces/ballerina/bi-diagram/src/components/nodes/PromptNode/PromptNodeWidget.tsx index 9034bfd428a..93523951da7 100644 --- a/workspaces/ballerina/bi-diagram/src/components/nodes/PromptNode/PromptNodeWidget.tsx +++ b/workspaces/ballerina/bi-diagram/src/components/nodes/PromptNode/PromptNodeWidget.tsx @@ -30,7 +30,9 @@ import { PROMPT_NODE_HEIGHT, PROMPT_NODE_WIDTH, } from "../../../resources/constants"; -import { Button, CompletionItem, FormExpressionEditor, FormExpressionEditorRef, Icon, Item, ThemeColors } from "@wso2/ui-toolkit"; +import { Button, Icon, Item, ThemeColors } from "@wso2/ui-toolkit"; +import { NPPromptEditor } from "../../editors/NPPromptEditor"; +import { InputMode } from "@wso2/ballerina-side-panel"; import NodeIcon from "../../NodeIcon"; import { useDiagramContext } from "../../DiagramContext"; import { PromptNodeModel } from "./PromptNodeModel"; @@ -216,6 +218,7 @@ export interface NodeWidgetProps extends Omit export function PromptNodeWidget(props: PromptNodeWidgetProps) { const { model, engine } = props; const { + flow, project, goToSource, openView, @@ -225,6 +228,11 @@ export function PromptNodeWidget(props: PromptNodeWidgetProps) { selectedNodeId, } = useDiagramContext(); + const npFunctionNode = useMemo( + () => flow.nodes.find(node => node.codedata?.node === "NP_FUNCTION"), + [flow.nodes] + ); + const isSelected = selectedNodeId === model.node.id; const { completions, @@ -233,7 +241,9 @@ export function PromptNodeWidget(props: PromptNodeWidgetProps) { onCompletionItemSelect, onFocus, onBlur, - onCancel + onCancel, + getHelperPane, + getExpressionTokens } = expressionContext; const [isHovered, setIsHovered] = useState(false); @@ -242,11 +252,7 @@ export function PromptNodeWidget(props: PromptNodeWidgetProps) { const hasBreakpoint = model.hasBreakpoint(); const isActiveBreakpoint = model.isActiveBreakpoint(); - const exprRef = useRef(null); - const anchorRef = useRef(null); const cursorPositionRef = useRef(undefined); - const fetchingStateRef = useRef(FETCH_COMPLETIONS_STATE.IDLE); - const invalidateCacheRef = useRef(false); const field: ExpressionProperty = useMemo(() => model.node.properties['prompt'], [model]); const nodeMetadata = (model.node.properties?.modelProvider?.metadata?.data as NodeMetadata); const nodeModelType = nodeMetadata?.type === "ModelProvider" ? nodeMetadata?.module : nodeMetadata?.type; @@ -309,6 +315,16 @@ export function PromptNodeWidget(props: PromptNodeWidgetProps) { }; const toggleEditable = () => { + if (editable) { + // Reset to original value when canceling + const prompt = model.node.properties?.['prompt']?.value as string; + if (!prompt) { + handleBodyTextChange(""); + } else { + const promptWithoutQuotes = prompt.replace(/^`|`$/g, ""); + handleBodyTextChange(promptWithoutQuotes); + } + } setEditable(!editable); }; @@ -352,94 +368,33 @@ export function PromptNodeWidget(props: PromptNodeWidgetProps) { } }, [model.node.properties]); - const handleCompletionSelect = async (value: string, item: CompletionItem) => { - // Trigger actions on completion select - await onCompletionItemSelect?.(value, item.additionalTextEdits); - - // Set cursor position - const cursorPosition = exprRef.current?.shadowRoot?.querySelector('textarea')?.selectionStart; - cursorPositionRef.current = cursorPosition; - }; - - const handleFocus = async () => { - // Retrive completions - const cursorPosition = exprRef.current?.shadowRoot?.querySelector('textarea')?.selectionStart; - cursorPositionRef.current = cursorPosition; - const triggerCharacter = - cursorPosition > 0 ? triggerCharacters.find((char) => bodyTextTemplate[cursorPosition - 1] === char) : undefined; - if (triggerCharacter) { - await retrieveCompletions( - bodyTextTemplate, - field, - cursorPosition + 1, - true, - triggerCharacter - ); - } else { - await retrieveCompletions( - bodyTextTemplate, - field, - cursorPosition + 1, - true - ); - } - - // Trigger actions on focus - await onFocus?.(); - }; - - const handleBlur = async () => { - // Trigger actions on blur - await onBlur?.(); - - // Clean up memory - cursorPositionRef.current = undefined; - }; - const handleChange = async (newValue: string, updatedCursorPosition: number) => { handleBodyTextChange(newValue); cursorPositionRef.current = updatedCursorPosition; - // Check state to invalidate completion cache - if ( - fetchingStateRef.current === FETCH_COMPLETIONS_STATE.IDLE && - isExpression(newValue, updatedCursorPosition) - ) { - invalidateCacheRef.current = true; - fetchingStateRef.current = FETCH_COMPLETIONS_STATE.FETCHING; - } else if ( - fetchingStateRef.current === FETCH_COMPLETIONS_STATE.FETCHING && - isExpression(newValue, updatedCursorPosition) - ) { - invalidateCacheRef.current = false; - fetchingStateRef.current = FETCH_COMPLETIONS_STATE.DONE; - } else if ( - fetchingStateRef.current === FETCH_COMPLETIONS_STATE.DONE && - !isExpression(newValue, updatedCursorPosition) - ) { - fetchingStateRef.current = FETCH_COMPLETIONS_STATE.IDLE; - } - // Check if the current character is a trigger character const triggerCharacter = updatedCursorPosition > 0 ? triggerCharacters.find((char) => newValue[updatedCursorPosition - 1] === char) : undefined; - if (triggerCharacter) { - await retrieveCompletions( - newValue, - field, - updatedCursorPosition + 1, - invalidateCacheRef.current, - triggerCharacter - ); - } else { - await retrieveCompletions( - newValue, - field, - updatedCursorPosition + 1, - invalidateCacheRef.current - ); + + try { + if (triggerCharacter) { + await retrieveCompletions( + newValue, + field, + updatedCursorPosition + 1, + triggerCharacter + ); + } else { + await retrieveCompletions( + newValue, + field, + updatedCursorPosition + 1, + ); + } + } catch (error) { + console.error('Failed to retrieve completions:', error); } } @@ -499,22 +454,25 @@ export function PromptNodeWidget(props: PromptNodeWidgetProps) { )} - diff --git a/workspaces/ballerina/bi-diagram/src/index.tsx b/workspaces/ballerina/bi-diagram/src/index.tsx index 40a00a7fc44..f4d70d53073 100644 --- a/workspaces/ballerina/bi-diagram/src/index.tsx +++ b/workspaces/ballerina/bi-diagram/src/index.tsx @@ -26,6 +26,7 @@ export { AIModelIcon } from "./components/AIModelIcon"; // types export type { FlowNodeStyle, DraftNodeConfig } from "./utils/types"; +export type { GetHelperPaneFunction } from "./components/DiagramContext"; // traversing utils export { traverseFlow, traverseNode } from "@wso2/ballerina-core"; From a3937508af8efb3be4f6c9fb16b35885815e0a63 Mon Sep 17 00:00:00 2001 From: Dan Niles Date: Sun, 30 Nov 2025 09:23:30 +0530 Subject: [PATCH 03/12] Add support for rich text editor for agent system prompt --- .../editors/ExpandedEditor/ExpandedEditor.tsx | 20 +- .../controls/RawTemplateMarkdownToolbar.tsx | 27 +- .../controls/RichTemplateMarkdownToolbar.tsx | 28 +- .../ExpandedEditor/modes/PromptMode.tsx | 266 ++++++++---------- .../ExpandedEditor/modes/TemplateMode.tsx | 109 ++----- .../components/editors/ExpressionEditor.tsx | 14 +- .../src/components/editors/TextAreaEditor.tsx | 4 +- 7 files changed, 194 insertions(+), 274 deletions(-) diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx index 631833c5a3b..5dc354c85ac 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/ExpandedEditor.tsx @@ -188,8 +188,8 @@ export const ExpandedEditor: React.FC = ({ }, [defaultMode]); useEffect(() => { - // Only text mode and prompt mode don't support preview - if (mode === "text") { + // Text mode and template mode don't support preview (simplified) + if (mode === "text" || mode === "template") { setShowPreview(false); } }, [mode]); @@ -220,10 +220,18 @@ export const ExpandedEditor: React.FC = ({ value: value, onChange: onChange, field, - // Props for modes with preview support + // Props for prompt mode (now uses EditorModeExpressionProps) ...(mode === "prompt" && { - isPreviewMode: showPreview, - onTogglePreview: (enabled: boolean) => setShowPreview(enabled) + completions, + fileName, + targetLineRange, + sanitizedExpression, + rawExpression, + extractArgsFromFunction, + getHelperPane, + error, + formDiagnostics, + inputMode }), // Props for expression mode ...(mode === "expression" && { @@ -237,7 +245,7 @@ export const ExpandedEditor: React.FC = ({ error, formDiagnostics }), - // Props for template mode (uses ProseMirror with source view toggle) + // Props for template mode (simplified - same as expression but with inputMode) ...(mode === "template" && { completions, fileName, diff --git a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx index fa774e304c8..5ffd0d4dc27 100644 --- a/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx +++ b/workspaces/ballerina/ballerina-side-panel/src/components/editors/ExpandedEditor/controls/RawTemplateMarkdownToolbar.tsx @@ -169,6 +169,7 @@ interface RawTemplateMarkdownToolbarProps { editorView: EditorView | null; isSourceView?: boolean; onToggleView?: () => void; + hideHelperPaneToggle?: boolean; helperPaneToggle?: { ref: React.RefObject; isOpen: boolean; @@ -180,6 +181,7 @@ export const RawTemplateMarkdownToolbar = React.forwardRef { const [, forceUpdate] = React.useReducer(x => x + 1, 0); @@ -250,20 +252,21 @@ export const RawTemplateMarkdownToolbar = React.forwardRef - {helperPaneToggle && ( - + {!hideHelperPaneToggle && helperPaneToggle && ( + <> + + + )} - - void; + hideHelperPaneToggle?: boolean; helperPaneToggle?: { ref: React.RefObject; isOpen: boolean; @@ -185,6 +186,7 @@ export const RichTemplateMarkdownToolbar = React.forwardRef { const [, forceUpdate] = React.useReducer(x => x + 1, 0); @@ -296,20 +298,22 @@ export const RichTemplateMarkdownToolbar = React.forwardRef - {helperPaneToggle && ( - + {!hideHelperPaneToggle && helperPaneToggle && ( + <> + + + + )} - - = ({ +export const PromptMode: React.FC = ({ value, onChange, - isPreviewMode, - onTogglePreview, - field + field, + completions = [], + fileName, + targetLineRange, + sanitizedExpression, + extractArgsFromFunction, + getHelperPane, + rawExpression, + error, + formDiagnostics, + inputMode }) => { - /** - * Handles Enter key to automatically continue lists (similar to GitHub comments) - */ - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) { - return; - } - - const textarea = e.currentTarget; - const cursorPosition = textarea.selectionStart; - const textBeforeCursor = value.substring(0, cursorPosition); - const textAfterCursor = value.substring(cursorPosition); - - // Find the start of the current line - const lastNewlineIndex = textBeforeCursor.lastIndexOf('\n'); - const currentLine = textBeforeCursor.substring(lastNewlineIndex + 1); - - // Check for unordered list (- or *) - const unorderedMatch = currentLine.match(/^(\s*)([-*])\s+(.*)$/); - if (unorderedMatch) { - const [, indent, marker, content] = unorderedMatch; - - // If the list item is empty (just the marker), remove it and exit list mode - if (!content.trim()) { - e.preventDefault(); - const newValue = textBeforeCursor.substring(0, lastNewlineIndex + 1) + '\n' + textAfterCursor; - onChange(newValue, cursorPosition); - // Set cursor position after both newlines - queueMicrotask(() => { - textarea.selectionStart = textarea.selectionEnd = lastNewlineIndex + 2; - }); - return; - } - - // Continue the list - e.preventDefault(); - const newValue = textBeforeCursor + '\n' + indent + marker + ' ' + textAfterCursor; - onChange(newValue, cursorPosition); - // Set cursor position after the list marker - queueMicrotask(() => { - const newCursorPos = cursorPosition + indent.length + marker.length + 2; - textarea.selectionStart = textarea.selectionEnd = newCursorPos; - }); - return; - } - - // Check for ordered list (1., 2., etc.) - const orderedMatch = currentLine.match(/^(\s*)(\d+)\.\s+(.*)$/); - if (orderedMatch) { - const [, indent, number, content] = orderedMatch; - - // If the list item is empty (just the number), remove it and exit list mode - if (!content.trim()) { - e.preventDefault(); - const newValue = textBeforeCursor.substring(0, lastNewlineIndex + 1) + '\n' + textAfterCursor; - onChange(newValue, cursorPosition); - // Set cursor position after both newlines - queueMicrotask(() => { - textarea.selectionStart = textarea.selectionEnd = lastNewlineIndex + 2; - }); - return; - } - - // Continue the list with incremented number - e.preventDefault(); - const nextNumber = parseInt(number, 10) + 1; - const newValue = textBeforeCursor + '\n' + indent + nextNumber + '. ' + textAfterCursor; - onChange(newValue, cursorPosition); - // Set cursor position after the list marker - queueMicrotask(() => { - const newCursorPos = cursorPosition + indent.length + nextNumber.toString().length + 3; - textarea.selectionStart = textarea.selectionEnd = newCursorPos; - }); - return; - } - - // Check for task list (- [ ] or - [x]) - const taskMatch = currentLine.match(/^(\s*)([-*])\s+\[([ x])\]\s+(.*)$/); - if (taskMatch) { - const [, indent, marker, , content] = taskMatch; - - // If the task item is empty, remove it and exit list mode - if (!content.trim()) { - e.preventDefault(); - const newValue = textBeforeCursor.substring(0, lastNewlineIndex + 1) + '\n' + textAfterCursor; - onChange(newValue, cursorPosition); - // Set cursor position after both newlines - queueMicrotask(() => { - textarea.selectionStart = textarea.selectionEnd = lastNewlineIndex + 2; - }); - return; - } + // Detect if this is a simple prompt field (text-only, no advanced features) + const isSimpleMode = SIMPLE_PROMPT_FIELDS.includes(field.key) && !getHelperPane; + + const [isSourceView, setIsSourceView] = useState(false); + const [codeMirrorView, setCodeMirrorView] = useState(null); + const [proseMirrorView, setProseMirrorView] = useState(null); + const [helperPaneToggle, setHelperPaneToggle] = useState<{ + ref: React.RefObject; + isOpen: boolean; + onClick: () => void; + } | null>(null); + const richToolbarRef = useRef(null); + const rawToolbarRef = useRef(null); + + // Convert onChange signature from (value: string) => void to (value: string, cursorPosition: number) => void + const handleChange = (updatedValue: string, updatedCursorPosition: number) => { + onChange(updatedValue, updatedCursorPosition); + }; - // Continue the task list with unchecked box - e.preventDefault(); - const newValue = textBeforeCursor + '\n' + indent + marker + ' [ ] ' + textAfterCursor; - onChange(newValue, cursorPosition); - // Set cursor position after the task marker - queueMicrotask(() => { - const newCursorPos = cursorPosition + indent.length + marker.length + 6; - textarea.selectionStart = textarea.selectionEnd = newCursorPos; - }); - return; - } + const handleHelperPaneStateChange = (state: { + isOpen: boolean; + ref: React.RefObject; + toggle: () => void; + }) => { + setHelperPaneToggle({ + ref: state.ref, + isOpen: state.isOpen, + onClick: state.toggle + }); }; - const placeholder = field.placeholder && field.placeholder.trim() !== "" && field.placeholder.trim() !== "\"\"" - ? field.placeholder - : "Enter your text here..."; + const handleToggleView = () => { + setIsSourceView(!isSourceView); + }; return ( <> - onTogglePreview(!isPreviewMode)} - /> - {isPreviewMode ? ( - + {isSourceView ? ( + ) : ( -