From 02714546713f422dfa9018756c4d8a118e695b32 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 4 Feb 2026 17:21:04 +0800 Subject: [PATCH] fix(web): replace code editor --- web/package.json | 10 + web/src/components/CodeMirrorEditor/index.tsx | 150 +++++++++++++++ web/src/styles/index.css | 5 + .../Workflow/components/Editor/index.tsx | 12 +- .../plugin/JavaScriptHighlightPlugin.tsx | 182 ------------------ .../Editor/plugin/Python3HighlightPlugin.tsx | 177 ----------------- .../Properties/CodeExecution/index.tsx | 7 +- 7 files changed, 174 insertions(+), 369 deletions(-) create mode 100644 web/src/components/CodeMirrorEditor/index.tsx delete mode 100644 web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx delete mode 100644 web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx diff --git a/web/package.json b/web/package.json index e28e8b56..89800fcf 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,14 @@ "@antv/layout": "^1.2.14-beta.8", "@antv/x6": "^3.0.1", "@antv/x6-react-shape": "^3.0.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.12", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -25,6 +33,7 @@ "antd": "^5.27.4", "axios": "^1.12.2", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "copy-to-clipboard": "^3.3.3", "crypto-js": "^4.2.0", "dayjs": "^1.11.18", @@ -55,6 +64,7 @@ "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.14", + "@types/codemirror": "^5.60.17", "@types/crypto-js": "^4.2.2", "@types/js-yaml": "^4.0.9", "@types/node": "^24.6.0", diff --git a/web/src/components/CodeMirrorEditor/index.tsx b/web/src/components/CodeMirrorEditor/index.tsx new file mode 100644 index 00000000..e100b75b --- /dev/null +++ b/web/src/components/CodeMirrorEditor/index.tsx @@ -0,0 +1,150 @@ +/* + * @Author: ZhaoYing + * @Date: 2026-02-04 17:20:52 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-04 17:20:52 + */ +import { useEffect, useRef, useMemo } from 'react'; +import { EditorView, basicSetup } from 'codemirror'; +import { EditorState } from '@codemirror/state'; +import { python } from '@codemirror/lang-python'; +import { javascript } from '@codemirror/lang-javascript'; +import { java } from '@codemirror/lang-java'; +import { cpp } from '@codemirror/lang-cpp'; +import { rust } from '@codemirror/lang-rust'; +import { oneDark } from '@codemirror/theme-one-dark'; + +/** + * Props for the CodeMirrorEditor component + * @property {string} value - The initial code content to display in the editor + * @property {string} language - Programming language for syntax highlighting (python, python3, javascript, typescript, java, cpp, c, rust) + * @property {function} onChange - Callback function triggered when editor content changes, receives the new code value + * @property {string} theme - Editor theme, either 'light' or 'dark' + * @property {boolean} readOnly - Whether the editor is read-only + * @property {string} height - Custom height for the editor + * @property {string} size - Predefined size preset: 'default' (120px min-height, 14px font) or 'small' (60px min-height, 12px font) + */ +interface CodeMirrorEditorProps { + value?: string; + language?: 'python' | 'python3' | 'javascript' | 'typescript' | 'java' | 'cpp' | 'c' | 'rust'; + onChange?: (value: string) => void; + theme?: 'light' | 'dark'; + readOnly?: boolean; + height?: string; + size?: 'default' | 'small'; +} + +/** + * Map of language identifiers to their corresponding CodeMirror language extensions + * Supports multiple programming languages with syntax highlighting + */ +const languageExtensions: Record = { + python: python(), + python3: python(), + javascript: javascript(), + typescript: javascript({ typescript: true }), + java: java(), + cpp: cpp(), + c: cpp(), + rust: rust(), +}; + +/** + * CodeMirrorEditor - A React wrapper component for CodeMirror 6 editor + * Provides a code editor with syntax highlighting, theme support, and customizable sizing + * Used in workflow code execution nodes for editing Python and JavaScript code + */ +const CodeMirrorEditor = ({ + value = '', + language = 'javascript', + onChange, + theme = 'light', + readOnly = false, + size, +}: CodeMirrorEditorProps) => { + // Reference to the DOM element that will contain the editor + const editorRef = useRef(null); + // Reference to the CodeMirror EditorView instance + const viewRef = useRef(null); + + /** + * Initialize CodeMirror editor when component mounts or when language/theme/readOnly changes + * Sets up extensions for syntax highlighting, change listeners, and theme + */ + useEffect(() => { + if (!editorRef.current) return; + + // Get the appropriate language extension, fallback to JavaScript if not found + const langExtension = languageExtensions[language] || languageExtensions.javascript; + + // Configure editor extensions + const extensions = [ + basicSetup, // Basic editor features (line numbers, bracket matching, etc.) + langExtension, // Language-specific syntax highlighting + // Listen for document changes and trigger onChange callback + EditorView.updateListener.of((update) => { + if (update.docChanged && onChange) { + onChange(update.state.doc.toString()); + } + }), + EditorState.readOnly.of(readOnly), // Set read-only mode + ]; + + // Apply dark theme if specified + if (theme === 'dark') { + extensions.push(oneDark); + } + + // Create editor state with initial value and extensions + const state = EditorState.create({ + doc: value, + extensions, + }); + + // Create and mount the editor view + viewRef.current = new EditorView({ + state, + parent: editorRef.current, + }); + + // Cleanup: destroy editor instance when component unmounts or dependencies change + return () => { + viewRef.current?.destroy(); + }; + }, [language, theme, readOnly]); + + /** + * Update editor content when the value prop changes externally + * Only updates if the new value differs from current editor content + */ + useEffect(() => { + if (viewRef.current && value !== viewRef.current.state.doc.toString()) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: viewRef.current.state.doc.length, + insert: value, + }, + }); + } + }, [value]); + + // Calculate minimum height based on size prop: small (60px) or default (120px) + const minHeight = useMemo(() => { + return `${size === 'small' ? 60 : 120}px` + }, [size]) + + // Calculate font size based on size prop: small (12px) or default (14px) + const fontSize = useMemo(() => { + return `${size === 'small' ? 12 : 14}px` + }, [size]) + + // Calculate line height based on size prop: small (16px) or default (20px) + const lineHeight = useMemo(() => { + return `${size === 'small' ? 16 : 20}px` + }, [size]) + + return
; +}; + +export default CodeMirrorEditor; diff --git a/web/src/styles/index.css b/web/src/styles/index.css index bbbe9cd9..d937396a 100644 --- a/web/src/styles/index.css +++ b/web/src/styles/index.css @@ -180,4 +180,9 @@ body { .x6-node foreignObject > body { min-height: 100%; max-height: 100%; +} + +.ΝΌ2 .cm-gutters { + background-color: #FFFFFF; + border: none; } \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx index 4c8540a8..60da03a7 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -15,8 +15,6 @@ import CharacterCountPlugin from './plugin/CharacterCountPlugin' import InitialValuePlugin from './plugin/InitialValuePlugin'; import CommandPlugin from './plugin/CommandPlugin'; import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin'; -import Python3HighlightPlugin from './plugin/Python3HighlightPlugin'; -import JavaScriptHighlightPlugin from './plugin/JavaScriptHighlightPlugin'; import LineNumberPlugin from './plugin/LineNumberPlugin'; import BlurPlugin from './plugin/BlurPlugin'; import { VariableNode } from './nodes/VariableNode' @@ -32,7 +30,7 @@ export interface LexicalEditorProps { lineHeight?: number; size?: 'default' | 'small'; type?: 'input' | 'textarea', - language?: 'string' | 'jinja2' | 'python3' | 'javascript' + language?: 'string' | 'jinja2' } const theme = { @@ -67,7 +65,7 @@ const Editor: FC =({ const [enableLineNumbers, setEnableLineNumbers] = useState(false) useEffect(() => { - const needsLineNumbers = language === 'jinja2' || language === 'python3' || language === 'javascript'; + const needsLineNumbers = language === 'jinja2'; setEnableJinja2(language === 'jinja2'); setEnableLineNumbers(needsLineNumbers); @@ -237,13 +235,11 @@ const Editor: FC =({ {language === 'jinja2' && } - {language === 'python3' && } - {language === 'javascript' && } {enableLineNumbers && } { setCount(count) }} onChange={onChange} /> - - {enableLineNumbers && } + + {enableJinja2 && }
); diff --git a/web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx deleted file mode 100644 index 21219139..00000000 --- a/web/src/views/Workflow/components/Editor/plugin/JavaScriptHighlightPlugin.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { TextNode, $createTextNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, PASTE_COMMAND } from 'lexical'; - -const JS_KEYWORDS = new Set([ - 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', - 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', - 'in', 'instanceof', 'let', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', - 'typeof', 'var', 'void', 'while', 'with', 'yield', 'true', 'false', 'null', 'undefined' -]); - -const JavaScriptHighlightPlugin = () => { - const [editor] = useLexicalComposerContext(); - const isPastingRef = useRef(false); - - useEffect(() => { - return editor.registerCommand( - PASTE_COMMAND, - () => { - isPastingRef.current = true; - setTimeout(() => { - isPastingRef.current = false; - }, 100); - return false; - }, - COMMAND_PRIORITY_LOW - ); - }, [editor]); - - useEffect(() => { - return editor.registerNodeTransform(TextNode, (textNode: TextNode) => { - if (isPastingRef.current) return; - - const text = textNode.getTextContent(); - - if (textNode.hasFormat('code')) return; - if (!needsHighlight(text)) return; - if (textNode.getStyle()) return; - - const parent = textNode.getParent(); - if (!parent) return; - - const selection = $getSelection(); - let selectionOffset = null; - if ($isRangeSelection(selection)) { - const anchor = selection.anchor; - if (anchor.getNode() === textNode) { - selectionOffset = anchor.offset; - } - } - - const tokens = tokenizeJavaScript(text); - if (tokens.length <= 1) return; - - const newNodes = tokens.map(token => { - const newNode = $createTextNode(token.text); - newNode.toggleFormat('code'); - - switch (token.type) { - case 'keyword': - newNode.setStyle('color: #d73a49; font-weight: 600;'); - break; - case 'string': - newNode.setStyle('color: #032f62;'); - break; - case 'comment': - newNode.setStyle('color: #6a737d; font-style: italic;'); - break; - case 'number': - newNode.setStyle('color: #005cc5; font-weight: 500;'); - break; - case 'function': - newNode.setStyle('color: #6f42c1; font-weight: 500;'); - break; - } - - return newNode; - }); - - if (newNodes.length > 1) { - textNode.replace(newNodes[0]); - for (let i = 1; i < newNodes.length; i++) { - newNodes[i - 1].insertAfter(newNodes[i]); - } - - if (selectionOffset !== null && $isRangeSelection(selection)) { - let currentOffset = 0; - for (const node of newNodes) { - const nodeLength = node.getTextContent().length; - if (currentOffset + nodeLength >= selectionOffset) { - node.select(selectionOffset - currentOffset, selectionOffset - currentOffset); - break; - } - currentOffset += nodeLength; - } - } - } - }); - }, [editor]); - - return null; -}; - -function needsHighlight(text: string): boolean { - return /[a-zA-Z0-9_/"'`]/.test(text); -} - -function tokenizeJavaScript(text: string): Array<{text: string, type: string}> { - const tokens: Array<{text: string, type: string}> = []; - let i = 0; - - while (i < text.length) { - // Single-line comments - if (text.slice(i, i + 2) === '//') { - let start = i; - while (i < text.length && text[i] !== '\n') i++; - tokens.push({ text: text.slice(start, i), type: 'comment' }); - continue; - } - - // Multi-line comments - if (text.slice(i, i + 2) === '/*') { - let start = i; - i += 2; - while (i < text.length && text.slice(i, i + 2) !== '*/') i++; - if (i < text.length) i += 2; - tokens.push({ text: text.slice(start, i), type: 'comment' }); - continue; - } - - // Strings - if (text[i] === '"' || text[i] === "'" || text[i] === '`') { - const quote = text[i]; - let start = i++; - - while (i < text.length) { - if (text[i] === quote && text[i - 1] !== '\\') { - i++; - break; - } - i++; - } - tokens.push({ text: text.slice(start, i), type: 'string' }); - continue; - } - - // Numbers - if (/\d/.test(text[i])) { - let start = i; - while (i < text.length && /[\d.]/.test(text[i])) i++; - tokens.push({ text: text.slice(start, i), type: 'number' }); - continue; - } - - // Keywords and identifiers - if (/[a-zA-Z_$]/.test(text[i])) { - let start = i; - while (i < text.length && /[a-zA-Z0-9_$]/.test(text[i])) i++; - const word = text.slice(start, i); - - if (JS_KEYWORDS.has(word)) { - tokens.push({ text: word, type: 'keyword' }); - } else if (i < text.length && text[i] === '(') { - tokens.push({ text: word, type: 'function' }); - } else { - tokens.push({ text: word, type: 'text' }); - } - continue; - } - - // Other characters - let start = i; - while (i < text.length && !/[a-zA-Z0-9_$/"'`]/.test(text[i])) i++; - if (start < i) { - tokens.push({ text: text.slice(start, i), type: 'text' }); - } - } - - return tokens; -} - -export default JavaScriptHighlightPlugin; diff --git a/web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx deleted file mode 100644 index 12830ffb..00000000 --- a/web/src/views/Workflow/components/Editor/plugin/Python3HighlightPlugin.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { TextNode, $createTextNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, PASTE_COMMAND } from 'lexical'; - -const PYTHON_KEYWORDS = new Set([ - 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', - 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', - 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', - 'with', 'yield' -]); - -const Python3HighlightPlugin = () => { - const [editor] = useLexicalComposerContext(); - const isPastingRef = useRef(false); - - useEffect(() => { - return editor.registerCommand( - PASTE_COMMAND, - () => { - isPastingRef.current = true; - setTimeout(() => { - isPastingRef.current = false; - }, 100); - return false; - }, - COMMAND_PRIORITY_LOW - ); - }, [editor]); - - useEffect(() => { - return editor.registerNodeTransform(TextNode, (textNode: TextNode) => { - if (isPastingRef.current) return; - - const text = textNode.getTextContent(); - - if (textNode.hasFormat('code')) return; - if (textNode.getStyle()) return; - if (!needsHighlight(text)) return; - - const parent = textNode.getParent(); - if (!parent) return; - - const selection = $getSelection(); - let selectionOffset = null; - if ($isRangeSelection(selection)) { - const anchor = selection.anchor; - if (anchor.getNode() === textNode) { - selectionOffset = anchor.offset; - } - } - - const tokens = tokenizePython(text); - if (tokens.length <= 1) return; - - const newNodes = tokens.map(token => { - const newNode = $createTextNode(token.text); - newNode.toggleFormat('code'); - - switch (token.type) { - case 'keyword': - newNode.setStyle('color: #d73a49; font-weight: 600;'); - break; - case 'string': - newNode.setStyle('color: #032f62;'); - break; - case 'comment': - newNode.setStyle('color: #6a737d; font-style: italic;'); - break; - case 'number': - newNode.setStyle('color: #005cc5; font-weight: 500;'); - break; - case 'function': - newNode.setStyle('color: #6f42c1; font-weight: 500;'); - break; - } - - return newNode; - }); - - if (newNodes.length > 1) { - textNode.replace(newNodes[0]); - for (let i = 1; i < newNodes.length; i++) { - newNodes[i - 1].insertAfter(newNodes[i]); - } - - if (selectionOffset !== null && $isRangeSelection(selection)) { - let currentOffset = 0; - for (const node of newNodes) { - const nodeLength = node.getTextContent().length; - if (currentOffset + nodeLength >= selectionOffset) { - node.select(selectionOffset - currentOffset, selectionOffset - currentOffset); - break; - } - currentOffset += nodeLength; - } - } - } - }); - }, [editor]); - - return null; -}; - -function needsHighlight(text: string): boolean { - return /[a-zA-Z0-9_#"']/.test(text); -} - -function tokenizePython(text: string): Array<{text: string, type: string}> { - const tokens: Array<{text: string, type: string}> = []; - let i = 0; - - while (i < text.length) { - // Comments - if (text[i] === '#') { - let start = i; - while (i < text.length && text[i] !== '\n') i++; - tokens.push({ text: text.slice(start, i), type: 'comment' }); - continue; - } - - // Strings - if (text[i] === '"' || text[i] === "'") { - const quote = text[i]; - let start = i++; - const isTriple = text.slice(start, start + 3) === quote.repeat(3); - if (isTriple) i += 2; - - while (i < text.length) { - if (isTriple && text.slice(i, i + 3) === quote.repeat(3)) { - i += 3; - break; - } else if (!isTriple && text[i] === quote && text[i - 1] !== '\\') { - i++; - break; - } - i++; - } - tokens.push({ text: text.slice(start, i), type: 'string' }); - continue; - } - - // Numbers - if (/\d/.test(text[i])) { - let start = i; - while (i < text.length && /[\d.]/.test(text[i])) i++; - tokens.push({ text: text.slice(start, i), type: 'number' }); - continue; - } - - // Keywords and identifiers - if (/[a-zA-Z_]/.test(text[i])) { - let start = i; - while (i < text.length && /[a-zA-Z0-9_]/.test(text[i])) i++; - const word = text.slice(start, i); - - if (PYTHON_KEYWORDS.has(word)) { - tokens.push({ text: word, type: 'keyword' }); - } else if (i < text.length && text[i] === '(') { - tokens.push({ text: word, type: 'function' }); - } else { - tokens.push({ text: word, type: 'text' }); - } - continue; - } - - // Other characters - let start = i; - while (i < text.length && !/[a-zA-Z0-9_#"']/.test(text[i])) i++; - if (start < i) { - tokens.push({ text: text.slice(start, i), type: 'text' }); - } - } - - return tokens; -} - -export default Python3HighlightPlugin; diff --git a/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx b/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx index 8a0ea03e..b9c2c881 100644 --- a/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx +++ b/web/src/views/Workflow/components/Properties/CodeExecution/index.tsx @@ -5,8 +5,8 @@ import { Node } from '@antv/x6' import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin' import MappingList from '../MappingList' -import Editor from '../../Editor' import OutputList from './OutputList' +import CodeMirrorEditor from '@/components/CodeMirrorEditor'; interface MappingItem { name?: string @@ -110,7 +110,10 @@ const CodeExecution: FC = ({ options }) => { prev.language !== curr.language}> {() => ( - + )}