Merge pull request #782 from SuanmoSuanyangTechnology/hotfix/v0.2.9
fix(web): string type language Editor init
This commit is contained in:
@@ -11,13 +11,13 @@ import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
|||||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||||
|
|
||||||
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin';
|
import { type Suggestion } from './plugin/AutocompletePlugin';
|
||||||
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
|
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
|
||||||
import InitialValuePlugin from './plugin/InitialValuePlugin';
|
import Jinja2InitialValuePlugin from './plugin/Jinja2InitialValuePlugin';
|
||||||
import CommandPlugin from './plugin/CommandPlugin';
|
import Jinja2AutocompletePlugin from './plugin/Jinja2AutocompletePlugin';
|
||||||
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
|
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
|
||||||
|
import Jinja2BlurPlugin from './plugin/Jinja2BlurPlugin';
|
||||||
import LineNumberPlugin from './plugin/LineNumberPlugin';
|
import LineNumberPlugin from './plugin/LineNumberPlugin';
|
||||||
import BlurPlugin from './plugin/BlurPlugin';
|
|
||||||
|
|
||||||
const jinja2Theme = {
|
const jinja2Theme = {
|
||||||
paragraph: 'editor-paragraph',
|
paragraph: 'editor-paragraph',
|
||||||
@@ -171,13 +171,12 @@ const Jinja2Editor: FC<Jinja2EditorProps> = ({
|
|||||||
ErrorBoundary={LexicalErrorBoundary}
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
/>
|
/>
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
<CommandPlugin />
|
|
||||||
<Jinja2HighlightPlugin />
|
<Jinja2HighlightPlugin />
|
||||||
<LineNumberPlugin />
|
<LineNumberPlugin />
|
||||||
<AutocompletePlugin options={options} enableJinja2 />
|
<Jinja2AutocompletePlugin options={options} />
|
||||||
<CharacterCountPlugin setCount={() => {}} onChange={onChange} />
|
<CharacterCountPlugin setCount={() => {}} onChange={onChange} waitForInit />
|
||||||
<InitialValuePlugin value={value} options={options} enableLineNumbers />
|
<Jinja2InitialValuePlugin value={value} />
|
||||||
<BlurPlugin enableJinja2 />
|
<Jinja2BlurPlugin />
|
||||||
</div>
|
</div>
|
||||||
</LexicalComposer>
|
</LexicalComposer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface LexicalEditorProps {
|
|||||||
type?: 'input' | 'textarea';
|
type?: 'input' | 'textarea';
|
||||||
language?: 'string' | 'jinja2';
|
language?: 'string' | 'jinja2';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
waitForInit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default theme for editor
|
// Default theme for editor
|
||||||
@@ -55,8 +56,10 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
type = 'textarea',
|
type = 'textarea',
|
||||||
language = 'string',
|
language = 'string',
|
||||||
height,
|
height,
|
||||||
className
|
className,
|
||||||
|
waitForInit = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
console.log('Editor value', value)
|
||||||
const [_count, setCount] = useState(0);
|
const [_count, setCount] = useState(0);
|
||||||
|
|
||||||
if (language === 'jinja2') {
|
if (language === 'jinja2') {
|
||||||
@@ -145,10 +148,10 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
/>
|
/>
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
<CommandPlugin />
|
<CommandPlugin />
|
||||||
<AutocompletePlugin options={options} enableJinja2={false} />
|
<AutocompletePlugin options={options} />
|
||||||
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} waitForInit={waitForInit || !!value} />
|
||||||
<InitialValuePlugin value={value} options={options} enableLineNumbers={false} />
|
<InitialValuePlugin value={value} options={options} />
|
||||||
<BlurPlugin enableJinja2={false} />
|
<BlurPlugin />
|
||||||
</div>
|
</div>
|
||||||
</LexicalComposer>
|
</LexicalComposer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2025-12-23 16:22:51
|
* @Date: 2025-12-23 16:22:51
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-25 16:13:37
|
* @Last Modified time: 2026-04-02 17:12:41
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useRef, type FC } from 'react';
|
import { useEffect, useState, useRef, type FC } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
|
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
|
||||||
import { Space, Flex } from 'antd';
|
import { Space, Flex } from 'antd';
|
||||||
|
|
||||||
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||||
@@ -26,7 +26,7 @@ export interface Suggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete plugin for variable suggestions triggered by '/' character
|
// Autocomplete plugin for variable suggestions triggered by '/' character
|
||||||
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
|
const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
@@ -129,34 +129,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
|||||||
|
|
||||||
// Insert selected suggestion into editor
|
// Insert selected suggestion into editor
|
||||||
const insertMention = (suggestion: Suggestion) => {
|
const insertMention = (suggestion: Suggestion) => {
|
||||||
if (enableJinja2) {
|
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||||
// In Jinja2 mode, insert {{variable}} format text
|
|
||||||
editor.update(() => {
|
|
||||||
const selection = $getSelection();
|
|
||||||
if ($isRangeSelection(selection)) {
|
|
||||||
const anchorNode = selection.anchor.getNode();
|
|
||||||
const anchorOffset = selection.anchor.offset;
|
|
||||||
const nodeText = anchorNode.getTextContent();
|
|
||||||
|
|
||||||
// Remove trigger character '/'
|
|
||||||
const textBefore = nodeText.substring(0, anchorOffset - 1);
|
|
||||||
const textAfter = nodeText.substring(anchorOffset);
|
|
||||||
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
|
|
||||||
|
|
||||||
if ($isTextNode(anchorNode)) {
|
|
||||||
anchorNode.setTextContent(newText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cursor position after inserted text
|
|
||||||
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
|
|
||||||
selection.anchor.offset = newOffset;
|
|
||||||
selection.focus.offset = newOffset;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// In normal mode, use VariableNode
|
|
||||||
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
|
||||||
}
|
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,33 @@
|
|||||||
/*
|
/*
|
||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-01-20 10:42:13
|
* @Date: 2026-01-20 10:42:13
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-03 10:12:10
|
* @Last Modified time: 2026-04-02 17:13:08
|
||||||
*/
|
*/
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { $setSelection } from 'lexical';
|
|
||||||
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||||
|
|
||||||
// Plugin to handle blur events and close autocomplete when clicking outside
|
// Plugin to handle blur events and close autocomplete when clicking outside
|
||||||
export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) {
|
export default function BlurPlugin() {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Close autocomplete when clicking outside the popup
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
|
||||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
|
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
return editor.registerRootListener((rootElement) => {
|
return editor.registerRootListener((rootElement) => {
|
||||||
if (rootElement) {
|
if (rootElement) {
|
||||||
const handleBlur = (e: FocusEvent) => {
|
|
||||||
if (enableJinja2) {
|
|
||||||
// Check if autocomplete popup was clicked
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if blur was caused by paste operation
|
|
||||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
|
||||||
if (!relatedTarget || relatedTarget === document.body) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear selection on blur
|
|
||||||
editor.update(() => {
|
|
||||||
$setSelection(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rootElement.addEventListener('blur', handleBlur);
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
rootElement.removeEventListener('blur', handleBlur);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return () => {
|
return () => { document.removeEventListener('mousedown', handleClickOutside); };
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, [editor, enableJinja2]);
|
}, [editor]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2025-12-23 16:22:51
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2026-04-02 17:13:45
|
||||||
|
*/
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { $getRoot, $isParagraphNode } from 'lexical';
|
import { $getRoot, $isParagraphNode } from 'lexical';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
|
||||||
import { $isVariableNode } from '../nodes/VariableNode';
|
import { $isVariableNode } from '../nodes/VariableNode';
|
||||||
|
|
||||||
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
|
const serialize = (root: ReturnType<typeof $getRoot>): string => {
|
||||||
|
const paragraphs: string[] = [];
|
||||||
|
root.getChildren().forEach(child => {
|
||||||
|
if ($isParagraphNode(child)) {
|
||||||
|
let content = '';
|
||||||
|
child.getChildren().forEach(node => {
|
||||||
|
content += $isVariableNode(node) ? node.getTextContent() : node.getTextContent();
|
||||||
|
});
|
||||||
|
paragraphs.push(content);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return paragraphs.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const CharacterCountPlugin = ({
|
||||||
|
setCount,
|
||||||
|
onChange,
|
||||||
|
waitForInit = false,
|
||||||
|
}: {
|
||||||
|
setCount: (count: number) => void;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
waitForInit?: boolean;
|
||||||
|
}) => {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const isReadyRef = useRef(false);
|
// lastProgrammaticValue tracks what InitialValuePlugin wrote, so we can
|
||||||
|
// suppress onChange when the content hasn't actually changed from that value.
|
||||||
|
const lastProgrammaticValueRef = useRef<string | null>(null);
|
||||||
|
const isReadyRef = useRef(!waitForInit);
|
||||||
|
const isFirstUpdateRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||||
if (tags.has('programmatic')) {
|
if (tags.has('programmatic')) {
|
||||||
isReadyRef.current = true;
|
isReadyRef.current = true;
|
||||||
|
isFirstUpdateRef.current = false;
|
||||||
|
editorState.read(() => {
|
||||||
|
lastProgrammaticValueRef.current = serialize($getRoot());
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isReadyRef.current) return;
|
if (!isReadyRef.current) return;
|
||||||
editorState.read(() => {
|
editorState.read(() => {
|
||||||
const root = $getRoot();
|
const content = serialize($getRoot());
|
||||||
let serializedContent = '';
|
// Skip the first update if content is empty (editor initial render)
|
||||||
|
if (isFirstUpdateRef.current) {
|
||||||
// Traverse all nodes and serialize properly
|
isFirstUpdateRef.current = false;
|
||||||
const paragraphs: string[] = [];
|
if (content === '') return;
|
||||||
root.getChildren().forEach(child => {
|
}
|
||||||
if ($isParagraphNode(child)) {
|
// Skip if content is identical to what was programmatically written
|
||||||
let paragraphContent = '';
|
if (content === lastProgrammaticValueRef.current) return;
|
||||||
child.getChildren().forEach(node => {
|
lastProgrammaticValueRef.current = null;
|
||||||
if ($isVariableNode(node)) {
|
setCount(content.length);
|
||||||
paragraphContent += node.getTextContent();
|
onChange?.(content);
|
||||||
} else {
|
|
||||||
paragraphContent += node.getTextContent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
paragraphs.push(paragraphContent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
serializedContent = paragraphs.join('\n');
|
|
||||||
|
|
||||||
setCount(serializedContent.length);
|
|
||||||
onChange?.(serializedContent);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [editor, setCount, onChange]);
|
}, [editor, setCount, onChange]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default CharacterCountPlugin
|
export default CharacterCountPlugin;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2025-12-23 16:22:51
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2026-04-02 17:14:15
|
||||||
|
*/
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||||
@@ -8,19 +14,17 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
|
|||||||
interface InitialValuePluginProps {
|
interface InitialValuePluginProps {
|
||||||
value: string;
|
value: string;
|
||||||
options?: Suggestion[];
|
options?: Suggestion[];
|
||||||
enableLineNumbers?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], enableLineNumbers = false }) => {
|
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const prevValueRef = useRef<string>('');
|
const prevValueRef = useRef<string>('');
|
||||||
const prevEnableLineNumbersRef = useRef<boolean>(enableLineNumbers);
|
|
||||||
const isUserInputRef = useRef(false);
|
const isUserInputRef = useRef(false);
|
||||||
const optionsRef = useRef(options);
|
const optionsRef = useRef(options);
|
||||||
optionsRef.current = options;
|
optionsRef.current = options;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeListener = editor.registerUpdateListener(({ editorState, tags }) => {
|
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||||
if (tags.has('programmatic')) return;
|
if (tags.has('programmatic')) return;
|
||||||
editorState.read(() => {
|
editorState.read(() => {
|
||||||
const root = $getRoot();
|
const root = $getRoot();
|
||||||
@@ -31,21 +35,16 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return removeListener;
|
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) {
|
if (value !== prevValueRef.current) {
|
||||||
// Skip reset if the change was triggered by user input (avoid cursor jump)
|
if (isUserInputRef.current) {
|
||||||
if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) {
|
|
||||||
prevValueRef.current = value;
|
prevValueRef.current = value;
|
||||||
isUserInputRef.current = false;
|
isUserInputRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Update refs BEFORE editor.update to prevent re-entry
|
|
||||||
prevValueRef.current = value;
|
prevValueRef.current = value;
|
||||||
prevEnableLineNumbersRef.current = enableLineNumbers;
|
|
||||||
isUserInputRef.current = false;
|
isUserInputRef.current = false;
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -54,16 +53,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
|||||||
root.clear();
|
root.clear();
|
||||||
|
|
||||||
const parts = value.split(/(\{\{[^}]+\}\}|\n)/);
|
const parts = value.split(/(\{\{[^}]+\}\}|\n)/);
|
||||||
|
let paragraph = $createParagraphNode();
|
||||||
if (enableLineNumbers) {
|
|
||||||
const lines = value.split('\n');
|
|
||||||
lines.forEach((line) => {
|
|
||||||
const paragraph = $createParagraphNode();
|
|
||||||
paragraph.append($createTextNode(line));
|
|
||||||
root.append(paragraph);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let paragraph = $createParagraphNode();
|
|
||||||
|
|
||||||
parts.forEach(part => {
|
parts.forEach(part => {
|
||||||
if (part === '\n') {
|
if (part === '\n') {
|
||||||
@@ -118,15 +108,10 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
root.append(paragraph);
|
root.append(paragraph);
|
||||||
}
|
|
||||||
}, { tag: 'programmatic' });
|
}, { tag: 'programmatic' });
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
prevValueRef.current = value;
|
|
||||||
prevEnableLineNumbersRef.current = enableLineNumbers;
|
|
||||||
isUserInputRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, [value, editor, enableLineNumbers]);
|
}, [value, editor]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2026-04-02 17:10:59
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2026-04-02 17:10:59
|
||||||
|
*/
|
||||||
|
import { useEffect, useState, useRef, type FC } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import {
|
||||||
|
$getSelection, $isRangeSelection, $isTextNode,
|
||||||
|
COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND,
|
||||||
|
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
|
||||||
|
} from 'lexical';
|
||||||
|
import { Space, Flex } from 'antd';
|
||||||
|
|
||||||
|
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||||
|
import type { Suggestion } from './AutocompletePlugin';
|
||||||
|
|
||||||
|
const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollSelectedIntoView = () => {
|
||||||
|
if (!popupRef.current) return;
|
||||||
|
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
|
||||||
|
if (!selectedElement) return;
|
||||||
|
const container = popupRef.current;
|
||||||
|
const element = selectedElement as HTMLElement;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
if (elementRect.bottom > containerRect.bottom) {
|
||||||
|
container.scrollTop += elementRect.bottom - containerRect.bottom;
|
||||||
|
} else if (elementRect.top < containerRect.top) {
|
||||||
|
container.scrollTop -= containerRect.top - elementRect.top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if (!selection || !$isRangeSelection(selection)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
const anchorOffset = selection.anchor.offset;
|
||||||
|
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
|
||||||
|
const shouldShow = textBeforeCursor.endsWith('/');
|
||||||
|
setShowSuggestions(shouldShow);
|
||||||
|
if (!shouldShow) { setSelectedIndex(0); return; }
|
||||||
|
|
||||||
|
const domSelection = window.getSelection();
|
||||||
|
if (domSelection && domSelection.rangeCount > 0) {
|
||||||
|
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
|
||||||
|
const popupWidth = 280, popupHeight = 200;
|
||||||
|
const vw = window.innerWidth, vh = window.innerHeight;
|
||||||
|
let left = Math.min(Math.max(rect.left, 10), vw - popupWidth - 10);
|
||||||
|
let top = rect.top - 10;
|
||||||
|
if (top - popupHeight < 10) {
|
||||||
|
top = Math.min(rect.bottom + 10, vh - popupHeight - 10);
|
||||||
|
}
|
||||||
|
setPopupPosition({ top, left });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerCommand(
|
||||||
|
CLOSE_AUTOCOMPLETE_COMMAND,
|
||||||
|
() => { setShowSuggestions(false); return true; },
|
||||||
|
COMMAND_PRIORITY_HIGH,
|
||||||
|
);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const insertMention = (suggestion: Suggestion) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if (!$isRangeSelection(selection)) return;
|
||||||
|
const anchorNode = selection.anchor.getNode();
|
||||||
|
const anchorOffset = selection.anchor.offset;
|
||||||
|
const nodeText = anchorNode.getTextContent();
|
||||||
|
const textBefore = nodeText.substring(0, anchorOffset - 1);
|
||||||
|
const textAfter = nodeText.substring(anchorOffset);
|
||||||
|
const inserted = `{{${suggestion.value}}}`;
|
||||||
|
if ($isTextNode(anchorNode)) {
|
||||||
|
anchorNode.setTextContent(textBefore + inserted + textAfter);
|
||||||
|
const newOffset = textBefore.length + inserted.length;
|
||||||
|
selection.anchor.offset = newOffset;
|
||||||
|
selection.focus.offset = newOffset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||||
|
const id = s.nodeData.id as string;
|
||||||
|
if (!groups[id]) groups[id] = [];
|
||||||
|
groups[id].push(s);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const allOptions = Object.values(groupedSuggestions).flat();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSuggestions) return;
|
||||||
|
return editor.registerCommand(
|
||||||
|
KEY_ENTER_COMMAND,
|
||||||
|
(event) => {
|
||||||
|
const opt = allOptions[selectedIndex];
|
||||||
|
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
COMMAND_PRIORITY_HIGH,
|
||||||
|
);
|
||||||
|
}, [showSuggestions, selectedIndex, allOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSuggestions) return;
|
||||||
|
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
setSelectedIndex(prev => {
|
||||||
|
let next = prev + 1;
|
||||||
|
while (next < allOptions.length && allOptions[next].disabled) next++;
|
||||||
|
setTimeout(scrollSelectedIntoView, 0);
|
||||||
|
return next >= allOptions.length ? prev : next;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, COMMAND_PRIORITY_HIGH);
|
||||||
|
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
setSelectedIndex(prev => {
|
||||||
|
let p = prev - 1;
|
||||||
|
while (p >= 0 && allOptions[p].disabled) p--;
|
||||||
|
setTimeout(scrollSelectedIntoView, 0);
|
||||||
|
return p < 0 ? prev : p;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}, COMMAND_PRIORITY_HIGH);
|
||||||
|
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
|
||||||
|
e?.preventDefault(); setShowSuggestions(false); return true;
|
||||||
|
}, COMMAND_PRIORITY_HIGH);
|
||||||
|
return () => { down(); up(); esc(); };
|
||||||
|
}, [showSuggestions, selectedIndex, allOptions, editor]);
|
||||||
|
|
||||||
|
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={popupRef}
|
||||||
|
data-autocomplete-popup="true"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||||
|
style={{ top: popupPosition.top, left: popupPosition.left }}
|
||||||
|
>
|
||||||
|
<Flex vertical gap={12}>
|
||||||
|
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
|
||||||
|
<div key={nodeId}>
|
||||||
|
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
|
||||||
|
{nodeOptions[0]?.nodeData?.icon && <img src={nodeOptions[0].nodeData.icon} className="rb:size-3" alt="" />}
|
||||||
|
{nodeOptions[0]?.nodeData?.name || nodeId}
|
||||||
|
</Flex>
|
||||||
|
{nodeOptions.map((option) => {
|
||||||
|
const globalIndex = allOptions.indexOf(option);
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
key={option.key}
|
||||||
|
data-selected={selectedIndex === globalIndex}
|
||||||
|
className="rb:pl-6! rb:pr-3! rb:py-2!"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
style={{
|
||||||
|
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||||
|
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
|
||||||
|
opacity: option.disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onClick={() => !option.disabled && insertMention(option)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||||
|
>
|
||||||
|
<Space size={4}>
|
||||||
|
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</Space>
|
||||||
|
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Jinja2AutocompletePlugin;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2026-04-02 17:11:04
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2026-04-02 17:11:04
|
||||||
|
*/
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { $setSelection } from 'lexical';
|
||||||
|
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||||
|
|
||||||
|
export default function Jinja2BlurPlugin() {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
|
||||||
|
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
|
return editor.registerRootListener((rootElement) => {
|
||||||
|
if (rootElement) {
|
||||||
|
const handleBlur = (e: FocusEvent) => {
|
||||||
|
if ((e.target as HTMLElement)?.closest('[data-autocomplete-popup="true"]')) return;
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||||
|
if (!relatedTarget || relatedTarget === document.body) return;
|
||||||
|
editor.update(() => { $setSelection(null); });
|
||||||
|
};
|
||||||
|
rootElement.addEventListener('blur', handleBlur);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
rootElement.removeEventListener('blur', handleBlur);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => { document.removeEventListener('mousedown', handleClickOutside); };
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* @Author: ZhaoYing
|
||||||
|
* @Date: 2026-04-02 17:11:07
|
||||||
|
* @Last Modified by: ZhaoYing
|
||||||
|
* @Last Modified time: 2026-04-02 17:11:07
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||||
|
|
||||||
|
interface Jinja2InitialValuePluginProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Jinja2InitialValuePlugin: React.FC<Jinja2InitialValuePluginProps> = ({ value }) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const prevValueRef = useRef<string>('');
|
||||||
|
const isUserInputRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||||
|
if (tags.has('programmatic')) return;
|
||||||
|
editorState.read(() => {
|
||||||
|
const textContent = $getRoot().getTextContent();
|
||||||
|
if (textContent !== prevValueRef.current) {
|
||||||
|
isUserInputRef.current = true;
|
||||||
|
prevValueRef.current = textContent;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === prevValueRef.current) return;
|
||||||
|
|
||||||
|
if (isUserInputRef.current) {
|
||||||
|
prevValueRef.current = value;
|
||||||
|
isUserInputRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevValueRef.current = value;
|
||||||
|
isUserInputRef.current = false;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
root.clear();
|
||||||
|
value.split('\n').forEach((line) => {
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
paragraph.append($createTextNode(line));
|
||||||
|
root.append(paragraph);
|
||||||
|
});
|
||||||
|
}, { tag: 'programmatic' });
|
||||||
|
});
|
||||||
|
}, [value, editor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Jinja2InitialValuePlugin;
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-09 18:35:43
|
* @Date: 2026-02-09 18:35:43
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-20 11:32:44
|
* @Last Modified time: 2026-04-02 17:17:06
|
||||||
*/
|
*/
|
||||||
import { type FC, useRef, useState } from "react";
|
import { type FC, useRef, useState } from "react";
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -114,6 +114,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
|||||||
<Col span={16}>
|
<Col span={16}>
|
||||||
<Form.Item name="url">
|
<Form.Item name="url">
|
||||||
<Editor
|
<Editor
|
||||||
|
key="url"
|
||||||
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
|
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
type="input"
|
type="input"
|
||||||
@@ -212,13 +213,15 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
|||||||
}
|
}
|
||||||
{values?.body?.content_type === 'binary' &&
|
{values?.body?.content_type === 'binary' &&
|
||||||
<Form.Item name={['body', 'data']}
|
<Form.Item name={['body', 'data']}
|
||||||
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:px-2! rb:py-1.5! rb:mb-0!"
|
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:mb-0!"
|
||||||
>
|
>
|
||||||
<Editor
|
<Editor
|
||||||
|
key={['body', 'data'].join('_')}
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={options.filter(vo => vo.dataType.includes('file'))}
|
options={options.filter(vo => vo.dataType.includes('file'))}
|
||||||
type="input"
|
type="input"
|
||||||
size="small"
|
size="small"
|
||||||
|
height={28}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user