From 52bc67d91d9ce18dfd74087c8b1638f73ef31e5c Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 26 Dec 2025 12:29:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20workflow=E2=80=99s=20Editor=20Vari?= =?UTF-8?q?able=20support=20Tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/package.json | 4 + .../components/Editor/commands/index.ts | 13 ++ .../Workflow/components/Editor/index.tsx | 24 +++- .../components/Editor/nodes/TagNode.tsx | 113 --------------- .../components/Editor/nodes/VariableNode.tsx | 133 ++++++++++++++++++ .../Editor/plugin/AutocompletePlugin.tsx | 132 +++++++---------- .../Editor/plugin/CharacterCountPlugin.tsx | 24 +++- .../Editor/plugin/CommandPlugin.tsx | 127 +++++++++++++++++ .../Editor/plugin/InitialValuePlugin.tsx | 42 ++++-- .../components/Properties/MessageEditor.tsx | 2 +- 10 files changed, 403 insertions(+), 211 deletions(-) create mode 100644 web/src/views/Workflow/components/Editor/commands/index.ts delete mode 100644 web/src/views/Workflow/components/Editor/nodes/TagNode.tsx create mode 100644 web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx create mode 100644 web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx diff --git a/web/package.json b/web/package.json index d6642ac8..e6c7483d 100644 --- a/web/package.json +++ b/web/package.json @@ -17,7 +17,11 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@lexical/code": "^0.39.0", + "@lexical/link": "^0.39.0", + "@lexical/list": "^0.39.0", "@lexical/react": "^0.39.0", + "@lexical/rich-text": "^0.39.0", "antd": "^5.27.4", "axios": "^1.12.2", "clsx": "^2.1.1", diff --git a/web/src/views/Workflow/components/Editor/commands/index.ts b/web/src/views/Workflow/components/Editor/commands/index.ts new file mode 100644 index 00000000..7f30c46a --- /dev/null +++ b/web/src/views/Workflow/components/Editor/commands/index.ts @@ -0,0 +1,13 @@ +import { createCommand, type LexicalCommand } from 'lexical'; +import type { Suggestion } from '../plugin/AutocompletePlugin'; + + +export interface InsertVariableCommandPayload { + data: Suggestion; +} + +export const INSERT_VARIABLE_COMMAND: LexicalCommand = createCommand('INSERT_VARIABLE_COMMAND'); + +export const CLEAR_EDITOR_COMMAND: LexicalCommand = createCommand('CLEAR_EDITOR_COMMAND'); + +export const FOCUS_EDITOR_COMMAND: LexicalCommand = createCommand('FOCUS_EDITOR_COMMAND'); \ 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 ac96dd73..9575b8fa 100644 --- a/web/src/views/Workflow/components/Editor/index.tsx +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -3,11 +3,18 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; -import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; +// import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +// import { HeadingNode, QuoteNode } from '@lexical/rich-text'; +// import { ListItemNode, ListNode } from '@lexical/list'; +// import { LinkNode } from '@lexical/link'; +// import { CodeNode } from '@lexical/code'; + import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin' import CharacterCountPlugin from './plugin/CharacterCountPlugin' import InitialValuePlugin from './plugin/InitialValuePlugin'; +import CommandPlugin from './plugin/CommandPlugin'; +import { VariableNode } from './nodes/VariableNode' interface LexicalEditorProps { placeholder?: string; @@ -30,10 +37,19 @@ const Editor: FC =({ onChange, suggestions, }) => { - const [count, setCount] = useState(0); + const [_count, setCount] = useState(0); const initialConfig = { namespace: 'AutocompleteEditor', theme, + nodes: [ + // HeadingNode, + // QuoteNode, + // ListItemNode, + // ListNode, + // LinkNode, + // CodeNode, + VariableNode + ], onError: (error: Error) => { console.error(error); }, @@ -74,10 +90,10 @@ const Editor: FC =({ ErrorBoundary={LexicalErrorBoundary} /> - + { setCount(count) }} onChange={onChange} /> - + ); diff --git a/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx b/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx deleted file mode 100644 index f4264bfe..00000000 --- a/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { - $applyNodeReplacement, - DecoratorNode, -} from 'lexical'; -import type { NodeKey, SerializedLexicalNode, Spread } from 'lexical'; -import React from 'react'; - -export type SerializedTagNode = Spread< - { - label: string; - tagType: string; - }, - SerializedLexicalNode ->; - -export class TagNode extends DecoratorNode { - __label: string; - __type: string; - - static getType(): string { - return 'tagNode'; - } - - static clone(node: TagNode): TagNode { - return new TagNode(node.__label, node.__type, node.__key); - } - - constructor(label: string, type: string, key?: NodeKey) { - super(key); - this.__label = label; - this.__type = type; - } - - createDOM(): HTMLElement { - return document.createElement('span'); - } - - updateDOM(): false { - return false; - } - - static importJSON(serializedNode: SerializedTagNode): TagNode { - const { label, tagType } = serializedNode; - return $createTagNode(label, tagType); - } - - exportJSON(): SerializedTagNode { - return { - label: this.__label, - tagType: this.__type, - type: 'tagNode', - version: 1, - }; - } - - getTextContent(): string { - return this.__label; - } - - decorate(): JSX.Element { - const getIconAndColor = (type: string) => { - switch (type) { - case 'context': - return { icon: '📄', bgColor: '#722ed1' }; - case 'system': - return { icon: 'x', bgColor: '#1890ff' }; - default: - return { icon: 'x', bgColor: '#52c41a' }; - } - }; - - const { icon, bgColor } = getIconAndColor(this.__type); - - return ( - - - {icon} - - {this.__label} - - ); - } -} - -export function $createTagNode(label: string, type: string): TagNode { - return new TagNode(label, type); -} - -export function $isTagNode(node: any): node is TagNode { - return node instanceof TagNode; -} \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx new file mode 100644 index 00000000..4b53c217 --- /dev/null +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import clsx from 'clsx' +import type { + EditorConfig, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import { + $applyNodeReplacement, + DecoratorNode, +} from 'lexical'; +import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'; +import type { Suggestion } from '../plugin/AutocompletePlugin'; + +export type SerializedVariableNode = Spread< + { + data: Suggestion; + }, + SerializedLexicalNode +>; + +const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ + nodeKey, + data, +}) => { + const [isSelected, setSelected] = useLexicalNodeSelection(nodeKey); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setSelected(!isSelected); + }; + + return ( + + + {data.nodeData?.name} + / + {data.label} + + ); +}; + +export class VariableNode extends DecoratorNode { + __data: Suggestion; + + static getType(): string { + return 'tag'; + } + + static clone(node: VariableNode): VariableNode { + return new VariableNode(node.__data, node.__key); + } + + constructor(data: Suggestion, key?: NodeKey) { + super(key); + this.__data = data; + } + + createDOM(_config: EditorConfig): HTMLElement { + const element = document.createElement('span'); + element.style.display = 'inline-block'; + return element; + } + + updateDOM(): false { + return false; + } + + decorate(): React.JSX.Element { + return ; + } + + getTextContent(): string { + return `{{${this.__data?.value}}}`; + } + + static importJSON(serializedNode: SerializedVariableNode): VariableNode { + const { data } = serializedNode; + return $createVariableNode(data); + } + + exportJSON(): SerializedVariableNode { + return { + data: this.__data, + type: 'tag', + version: 1, + }; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + canBeEmpty(): boolean { + return false; + } + + isInline(): true { + return true; + } + + isKeyboardSelectable(): boolean { + return true; + } +} + +export function $createVariableNode(data: Suggestion): VariableNode { + return $applyNodeReplacement(new VariableNode(data)); +} + +export function $isVariableNode( + node: LexicalNode | null | undefined, +): node is VariableNode { + return node instanceof VariableNode; +} \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 79bd857b..4a86332f 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -1,7 +1,10 @@ import { useEffect, useState, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $getRoot, $createTextNode, $createParagraphNode, $setSelection, $createRangeSelection, $getSelection } from 'lexical'; +import { $getRoot, $getSelection } from 'lexical'; + +import { INSERT_VARIABLE_COMMAND } from '../commands'; import type { NodeProperties } from '../../../types' + export interface Suggestion { key: string; label: string; @@ -10,6 +13,7 @@ export interface Suggestion { value: string; nodeData: NodeProperties } + const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => { const [editor] = useLexicalComposerContext(); const [showSuggestions, setShowSuggestions] = useState(false); @@ -32,19 +36,14 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) const range = domSelection.getRangeAt(0); const rect = range.getBoundingClientRect(); - // Calculate popup dimensions const popupWidth = 280; const popupHeight = 200; - - // Get viewport dimensions const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - // Calculate position with viewport constraints let left = rect.left; let top = rect.top - 10; - // Adjust horizontal position if popup would overflow if (left + popupWidth > viewportWidth) { left = viewportWidth - popupWidth - 10; } @@ -52,9 +51,7 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) left = 10; } - // Adjust vertical position if popup would overflow if (top - popupHeight < 10) { - // Show below cursor if not enough space above top = rect.bottom + 10; if (top + popupHeight > viewportHeight) { top = viewportHeight - popupHeight - 10; @@ -69,31 +66,8 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) }); }, [editor]); - const insertMention = (suggestion: any) => { - editor.update(() => { - const root = $getRoot(); - const text = root.getTextContent(); - const lastSlashIndex = text.lastIndexOf('/'); - const beforeSlash = text.slice(0, lastSlashIndex); - const afterSlash = text.slice(lastSlashIndex + 1); - const insertedText = `{{${suggestion.value}}} `; - const newText = beforeSlash + insertedText + afterSlash; - const cursorPosition = beforeSlash.length + insertedText.length; - - root.clear(); - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(newText)); - root.append(paragraph); - - // Set cursor after the inserted text - const textNode = paragraph.getFirstChild(); - if (textNode) { - const selection = $createRangeSelection(); - selection.anchor.set(textNode.getKey(), cursorPosition, 'text'); - selection.focus.set(textNode.getKey(), cursorPosition, 'text'); - $setSelection(selection); - } - }); + const insertMention = (suggestion: Suggestion) => { + editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); setShowSuggestions(false); }; @@ -131,53 +105,53 @@ const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; return (
- {groupIndex > 0 &&
} -
- {nodeName} -
- {nodeOptions.map((option, index) => { - const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option); - return ( -
insertMention(option)} - onMouseEnter={() => setSelectedIndex(globalIndex)} - > -
- - {option.type === 'context' ? '📄' : - option.type === 'system' ? 'x' : 'x'} - - {option.label} + {groupIndex > 0 &&
} +
+ {nodeName} +
+ {nodeOptions.map((option, index) => { + const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option); + return ( +
insertMention(option)} + onMouseEnter={() => setSelectedIndex(globalIndex)} + > +
+ + {option.type === 'context' ? '📄' : + option.type === 'system' ? 'x' : 'x'} + + {option.label} +
+ {option.dataType && ( + + {option.dataType} + + )}
- {option.dataType && ( - - {option.dataType} - - )} -
- ); - })} -
+ ); + })} +
); })}
diff --git a/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx index 911eff3d..963f824b 100644 --- a/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx @@ -1,7 +1,9 @@ import { useEffect } from 'react'; -import { $getRoot } from 'lexical'; +import { $getRoot, $isParagraphNode } from 'lexical'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $isVariableNode } from '../nodes/VariableNode'; + const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => { const [editor] = useLexicalComposerContext(); @@ -9,9 +11,23 @@ const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number return editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { const root = $getRoot(); - const textContent = root.getTextContent(); - setCount(textContent.length); - onChange?.(textContent); + let serializedContent = ''; + + // Traverse all nodes and serialize properly + root.getChildren().forEach(child => { + if ($isParagraphNode(child)) { + child.getChildren().forEach(node => { + if ($isVariableNode(node)) { + serializedContent += node.getTextContent(); + } else { + serializedContent += node.getTextContent(); + } + }); + } + }); + + setCount(serializedContent.length); + onChange?.(serializedContent); }); }); }, [editor, setCount, onChange]); diff --git a/web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx new file mode 100644 index 00000000..7393130e --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/CommandPlugin.tsx @@ -0,0 +1,127 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $setSelection, + $createRangeSelection, + $isParagraphNode, + $isTextNode, +} from 'lexical'; + +import { $createVariableNode } from '../nodes/VariableNode'; +import { + INSERT_VARIABLE_COMMAND, + CLEAR_EDITOR_COMMAND, + FOCUS_EDITOR_COMMAND, + type InsertVariableCommandPayload, +} from '../commands'; + +const CommandPlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const unregisterInsertVariable = editor.registerCommand( + INSERT_VARIABLE_COMMAND, + (payload: InsertVariableCommandPayload) => { + editor.update(() => { + const root = $getRoot(); + const text = root.getTextContent(); + const lastSlashIndex = text.lastIndexOf('/'); + + // Find the paragraph and the position to insert + const paragraph = root.getFirstChild(); + if (!paragraph || !$isParagraphNode(paragraph)) return; + + const children = paragraph.getChildren(); + let insertPosition = 0; + let currentTextLength = 0; + + // Find where to insert the new tag + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childText = child.getTextContent(); + + if (currentTextLength + childText.length > lastSlashIndex) { + // Split this text node if needed + if ($isTextNode(child)) { + const beforeSlash = childText.substring(0, lastSlashIndex - currentTextLength); + const afterSlash = childText.substring(lastSlashIndex - currentTextLength + 1); + + if (beforeSlash) { + child.setTextContent(beforeSlash); + insertPosition = i + 1; + } else { + insertPosition = i; + child.remove(); + } + + // Insert tag and space + const tagNode = $createVariableNode(payload.data); + const spaceNode = $createTextNode(' '); + + if (insertPosition < paragraph.getChildrenSize()) { + paragraph.getChildAtIndex(insertPosition)?.insertBefore(tagNode); + tagNode.insertAfter(spaceNode); + } else { + paragraph.append(tagNode); + paragraph.append(spaceNode); + } + + if (afterSlash) { + spaceNode.insertAfter($createTextNode(afterSlash)); + } + + // Set cursor after space + const selection = $createRangeSelection(); + selection.anchor.set(spaceNode.getKey(), 1, 'text'); + selection.focus.set(spaceNode.getKey(), 1, 'text'); + $setSelection(selection); + } + break; + } + + currentTextLength += childText.length; + insertPosition = i + 1; + } + }); + return true; + }, + 1 + ); + + const unregisterClearEditor = editor.registerCommand( + CLEAR_EDITOR_COMMAND, + () => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + root.append(paragraph); + }); + return true; + }, + 1 + ); + + const unregisterFocusEditor = editor.registerCommand( + FOCUS_EDITOR_COMMAND, + () => { + editor.focus(); + return true; + }, + 1 + ); + + return () => { + unregisterInsertVariable(); + unregisterClearEditor(); + unregisterFocusEditor(); + }; + }, [editor]); + + return null; +}; + +export default CommandPlugin; \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx index 912801d8..05043436 100644 --- a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx @@ -1,27 +1,49 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical'; +import { $createVariableNode } from '../nodes/VariableNode'; +import { type Suggestion } from '../plugin/AutocompletePlugin' + interface InitialValuePluginProps { value: string; + suggestions?: Suggestion[]; } -const InitialValuePlugin: React.FC = ({ value }) => { +const InitialValuePlugin: React.FC = ({ value, suggestions = [] }) => { const [editor] = useLexicalComposerContext(); + const initializedRef = useRef(false); useEffect(() => { - if (value) { + if (!initializedRef.current && value) { editor.update(() => { const root = $getRoot(); - if (root.getTextContent() === '') { - root.clear(); - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(value)); - root.append(paragraph); - } + root.clear(); + const paragraph = $createParagraphNode(); + + const parts = value.split(/(\{\{[^}]+\}\})/); + + parts.forEach(part => { + const match = part.match(/^\{\{([^.]+)\.([^}]+)\}\}$/); + if (match) { + const [, nodeId, label] = match; + const suggestion = suggestions.find(s => s.nodeData.id === nodeId && s.label === label); + if (suggestion) { + paragraph.append($createVariableNode(suggestion)); + } else { + paragraph.append($createTextNode(part)); + } + } else if (part) { + paragraph.append($createTextNode(part)); + } + }); + + root.append(paragraph); }); + + initializedRef.current = true; } - }, [editor, value]); + }, []); return null; }; diff --git a/web/src/views/Workflow/components/Properties/MessageEditor.tsx b/web/src/views/Workflow/components/Properties/MessageEditor.tsx index dfb3ccdb..879f5072 100644 --- a/web/src/views/Workflow/components/Properties/MessageEditor.tsx +++ b/web/src/views/Workflow/components/Properties/MessageEditor.tsx @@ -85,7 +85,7 @@ const MessageEditor: FC = ({ nodeData.config?.variables?.sys.forEach((variable: any) => { suggestions.push({ key: `${nodeId}_${variable.name}`, - label: variable.name, + label: `sys.${variable.name}`, type: 'variable', dataType: variable.type, value: `sys.${variable.name}`,