feat(web): workflow’s Editor Variable support Tag

This commit is contained in:
zhaoying
2025-12-26 12:29:46 +08:00
parent a0a3997af2
commit 52bc67d91d
10 changed files with 403 additions and 211 deletions

View File

@@ -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",

View File

@@ -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<InsertVariableCommandPayload> = createCommand('INSERT_VARIABLE_COMMAND');
export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand('CLEAR_EDITOR_COMMAND');
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand('FOCUS_EDITOR_COMMAND');

View File

@@ -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<LexicalEditorProps> =({
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<LexicalEditorProps> =({
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<AutoFocusPlugin />
<CommandPlugin />
<AutocompletePlugin suggestions={suggestions} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} />
<InitialValuePlugin value={value} suggestions={suggestions} />
</div>
</LexicalComposer>
);

View File

@@ -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<JSX.Element> {
__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 (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
background: '#f0f8ff',
border: '1px solid #d9d9d9',
borderRadius: '4px',
padding: '2px 6px',
fontSize: '14px',
margin: '0 2px',
}}
>
<span
style={{
background: bgColor,
color: 'white',
padding: '1px 4px',
borderRadius: '2px',
fontSize: '10px',
minWidth: '12px',
textAlign: 'center',
}}
>
{icon}
</span>
<span>{this.__label}</span>
</span>
);
}
}
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;
}

View File

@@ -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 (
<span
onClick={handleClick}
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:py-0.5 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', {
'rb:border-[#155EEF]': isSelected,
'rb:border-[#DFE4ED]': !isSelected
})}
contentEditable={false}
>
<img
src={data.nodeData?.icon}
style={{ width: '12px', height: '12px', marginRight: '4px' }}
alt=""
/>
{data.nodeData?.name}
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
<span style={{ color: '#155EEF' }}>{data.label}</span>
</span>
);
};
export class VariableNode extends DecoratorNode<React.JSX.Element> {
__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 <VariableComponent nodeKey={this.__key} data={this.__data} />;
}
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;
}

View File

@@ -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 (
<div key={nodeId}>
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{nodeName}
</div>
{nodeOptions.map((option, index) => {
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
return (
<div
key={option.key}
style={{
padding: '8px 12px',
cursor: 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
onClick={() => insertMention(option)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
background: option.type === 'context' ? '#722ed1' :
option.type === 'system' ? '#1890ff' : '#52c41a',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
minWidth: '16px',
textAlign: 'center',
}}
>
{option.type === 'context' ? '📄' :
option.type === 'system' ? 'x' : 'x'}
</span>
<span style={{ fontSize: '14px' }}>{option.label}</span>
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{nodeName}
</div>
{nodeOptions.map((option, index) => {
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
return (
<div
key={option.key}
style={{
padding: '8px 12px',
cursor: 'pointer',
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
onClick={() => insertMention(option)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
background: option.type === 'context' ? '#722ed1' :
option.type === 'system' ? '#1890ff' : '#52c41a',
color: 'white',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
minWidth: '16px',
textAlign: 'center',
}}
>
{option.type === 'context' ? '📄' :
option.type === 'system' ? 'x' : 'x'}
</span>
<span style={{ fontSize: '14px' }}>{option.label}</span>
</div>
{option.dataType && (
<span style={{ fontSize: '12px', color: '#999' }}>
{option.dataType}
</span>
)}
</div>
{option.dataType && (
<span style={{ fontSize: '12px', color: '#999' }}>
{option.dataType}
</span>
)}
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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<InitialValuePluginProps> = ({ value }) => {
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ 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;
};

View File

@@ -85,7 +85,7 @@ const MessageEditor: FC<TextareaProps> = ({
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}`,