feat(web): workflow’s Editor Variable support Tag
This commit is contained in:
@@ -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",
|
||||
|
||||
13
web/src/views/Workflow/components/Editor/commands/index.ts
Normal file
13
web/src/views/Workflow/components/Editor/commands/index.ts
Normal 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');
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
133
web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx
Normal file
133
web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user