fix(web): Editor input type add blur event

This commit is contained in:
zhaoying
2026-03-03 10:14:36 +08:00
parent ce8a2cbe34
commit aa733354e8
5 changed files with 123 additions and 32 deletions

View File

@@ -1,13 +1,25 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-23 12:29:46
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 10:12:48
*/
import { createCommand, type LexicalCommand } from 'lexical'; import { createCommand, type LexicalCommand } from 'lexical';
import type { Suggestion } from '../plugin/AutocompletePlugin'; import type { Suggestion } from '../plugin/AutocompletePlugin';
// Payload interface for inserting variable command
export interface InsertVariableCommandPayload { export interface InsertVariableCommandPayload {
data: Suggestion; data: Suggestion;
} }
// Command to insert a variable into the editor
export const INSERT_VARIABLE_COMMAND: LexicalCommand<InsertVariableCommandPayload> = createCommand('INSERT_VARIABLE_COMMAND'); export const INSERT_VARIABLE_COMMAND: LexicalCommand<InsertVariableCommandPayload> = createCommand('INSERT_VARIABLE_COMMAND');
// Command to clear all editor content
export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand('CLEAR_EDITOR_COMMAND'); export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand('CLEAR_EDITOR_COMMAND');
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand('FOCUS_EDITOR_COMMAND'); // Command to focus the editor
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand('FOCUS_EDITOR_COMMAND');
// Command to close the autocomplete dropdown
export const CLOSE_AUTOCOMPLETE_COMMAND: LexicalCommand<void> = createCommand('CLOSE_AUTOCOMPLETE_COMMAND');

View File

@@ -1,3 +1,9 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 10:11:48
*/
import { type FC, useState, useEffect, useMemo } from 'react'; import { type FC, useState, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
@@ -19,6 +25,7 @@ import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin'; import BlurPlugin from './plugin/BlurPlugin';
import { VariableNode } from './nodes/VariableNode' import { VariableNode } from './nodes/VariableNode'
// Props interface for Lexical Editor component
export interface LexicalEditorProps { export interface LexicalEditorProps {
placeholder?: string; placeholder?: string;
value?: string; value?: string;
@@ -34,6 +41,7 @@ export interface LexicalEditorProps {
className?: string; className?: string;
} }
// Default theme for editor
const theme = { const theme = {
paragraph: 'editor-paragraph', paragraph: 'editor-paragraph',
text: { text: {
@@ -42,6 +50,7 @@ const theme = {
}, },
}; };
// Theme with Jinja2 syntax highlighting
const jinja2Theme = { const jinja2Theme = {
...theme, ...theme,
code: 'jinja2-expression', code: 'jinja2-expression',
@@ -51,7 +60,8 @@ const jinja2Theme = {
}, },
}; };
const Editor: FC<LexicalEditorProps> =({ // Main Lexical Editor component
const Editor: FC<LexicalEditorProps> =(({
placeholder = "请输入内容...", placeholder = "请输入内容...",
value = "", value = "",
onChange, onChange,
@@ -67,6 +77,7 @@ const Editor: FC<LexicalEditorProps> =({
const [enableJinja2, setEnableJinja2] = useState(false) const [enableJinja2, setEnableJinja2] = useState(false)
const [enableLineNumbers, setEnableLineNumbers] = useState(false) const [enableLineNumbers, setEnableLineNumbers] = useState(false)
// Setup Jinja2 mode and inject styles when language changes
useEffect(() => { useEffect(() => {
const needsLineNumbers = language === 'jinja2'; const needsLineNumbers = language === 'jinja2';
setEnableJinja2(language === 'jinja2'); setEnableJinja2(language === 'jinja2');
@@ -139,11 +150,12 @@ const Editor: FC<LexicalEditorProps> =({
} }
}, [language]) }, [language])
// Lexical editor configuration
const initialConfig = { const initialConfig = {
namespace: 'AutocompleteEditor', namespace: 'AutocompleteEditor',
theme: enableJinja2 ? jinja2Theme : theme, theme: enableJinja2 ? jinja2Theme : theme,
nodes: enableJinja2 ? [ nodes: enableJinja2 ? [
// 当启用jinja2时不使用VariableNode,使用普通文本 // When Jinja2 is enabled, use plain text instead of VariableNode
] : [ ] : [
// HeadingNode, // HeadingNode,
// QuoteNode, // QuoteNode,
@@ -157,18 +169,26 @@ const Editor: FC<LexicalEditorProps> =({
console.error(error); console.error(error);
}, },
}; };
// Calculate minimum height based on type and size
const minheight = useMemo(() => { const minheight = useMemo(() => {
if (type === 'input') { if (type === 'input') {
return `${height ? height : size === 'small' ? 28 : 30}px` return `${height ? height : size === 'small' ? 28 : 30}px`
} }
return `${height ? height : size === 'small' ? 60 : 120}px` return `${height ? height : size === 'small' ? 60 : 120}px`
}, [type, size, height]) }, [type, size, height])
// Calculate font size based on size prop
const fontSize = useMemo(() => { const fontSize = useMemo(() => {
return `${size === 'small' ? 12 : 14}px` return `${size === 'small' ? 12 : 14}px`
}, [size]) }, [size])
// Calculate line height based on size prop
const lineHeight = useMemo(() => { const lineHeight = useMemo(() => {
return `${height ? height : size === 'small' ? 16 : 20}px` return `${height ? height : size === 'small' ? 16 : 20}px`
}, [size]) }, [size])
// Calculate placeholder minimum height
const placeHolderMinheight = useMemo(() => { const placeHolderMinheight = useMemo(() => {
return `${height ? height : size === 'small' ? 16 : 30}px` return `${height ? height : size === 'small' ? 16 : 30}px`
}, [type, size, height]) }, [type, size, height])
@@ -179,6 +199,7 @@ const Editor: FC<LexicalEditorProps> =({
<RichTextPlugin <RichTextPlugin
contentEditable={ contentEditable={
enableLineNumbers ? ( enableLineNumbers ? (
// Editor with line numbers for Jinja2 mode
<div className="editor-with-line-numbers" style={{ <div className="editor-with-line-numbers" style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED', border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px', borderRadius: '6px',
@@ -203,6 +224,7 @@ const Editor: FC<LexicalEditorProps> =({
</div> </div>
</div> </div>
) : ( ) : (
// Standard editor without line numbers
<ContentEditable <ContentEditable
style={{ style={{
minHeight: minheight, minHeight: minheight,
@@ -235,6 +257,7 @@ const Editor: FC<LexicalEditorProps> =({
} }
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
/> />
{/* Editor plugins */}
<HistoryPlugin /> <HistoryPlugin />
<CommandPlugin /> <CommandPlugin />
{language === 'jinja2' && <Jinja2HighlightPlugin />} {language === 'jinja2' && <Jinja2HighlightPlugin />}
@@ -242,10 +265,10 @@ const Editor: FC<LexicalEditorProps> =({
<AutocompletePlugin options={options} enableJinja2={enableJinja2} /> <AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} /> <CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} /> <InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
{enableJinja2 && <BlurPlugin />} <BlurPlugin enableJinja2={enableJinja2} />
</div> </div>
</LexicalComposer> </LexicalComposer>
); );
}; });
export default Editor; export default Editor;

View File

@@ -1,10 +1,17 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 10:12:33
*/
import { useEffect, useState, type FC } from 'react'; import { useEffect, useState, 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, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { INSERT_VARIABLE_COMMAND } from '../commands'; import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { NodeProperties } from '../../../types' import type { NodeProperties } from '../../../types'
// Suggestion item interface for autocomplete dropdown
export interface Suggestion { export interface Suggestion {
key: string; key: string;
label: string; label: string;
@@ -13,16 +20,18 @@ export interface Suggestion {
value: string; value: string;
group?: string group?: string
nodeData: NodeProperties; nodeData: NodeProperties;
isContext?: boolean; // 标记是否为context变量 isContext?: boolean; // Flag for context variable
disabled?: boolean; // 标记是否禁用 disabled?: boolean; // Flag for disabled state
} }
// Autocomplete plugin for variable suggestions triggered by '/' character
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => { const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
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);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 }); const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
// Listen to editor updates and show suggestions when '/' is typed
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => { return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => { editorState.read(() => {
@@ -49,6 +58,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
setSelectedIndex(0); setSelectedIndex(0);
} }
// Calculate popup position to keep it within viewport bounds
if (shouldShow) { if (shouldShow) {
const domSelection = window.getSelection(); const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) { if (domSelection && domSelection.rangeCount > 0) {
@@ -84,9 +94,22 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
}); });
}, [editor]); }, [editor]);
// Register command to close autocomplete popup
useEffect(() => {
return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND,
() => {
setShowSuggestions(false);
return true;
},
COMMAND_PRIORITY_HIGH
);
}, [editor]);
// Insert selected suggestion into editor
const insertMention = (suggestion: Suggestion) => { const insertMention = (suggestion: Suggestion) => {
if (enableJinja2) { if (enableJinja2) {
// 在jinja2模式下,插入{{variable}}格式的文本 // In Jinja2 mode, insert {{variable}} format text
editor.update(() => { editor.update(() => {
const selection = $getSelection(); const selection = $getSelection();
if ($isRangeSelection(selection)) { if ($isRangeSelection(selection)) {
@@ -94,7 +117,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
const anchorOffset = selection.anchor.offset; const anchorOffset = selection.anchor.offset;
const nodeText = anchorNode.getTextContent(); const nodeText = anchorNode.getTextContent();
// 移除触发字符'/' // Remove trigger character '/'
const textBefore = nodeText.substring(0, anchorOffset - 1); const textBefore = nodeText.substring(0, anchorOffset - 1);
const textAfter = nodeText.substring(anchorOffset); const textAfter = nodeText.substring(anchorOffset);
const newText = textBefore + `{{${suggestion.value}}}` + textAfter; const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
@@ -103,19 +126,20 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
anchorNode.setTextContent(newText); anchorNode.setTextContent(newText);
} }
// 设置光标位置到插入文本之后 // Set cursor position after inserted text
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length; const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
selection.anchor.offset = newOffset; selection.anchor.offset = newOffset;
selection.focus.offset = newOffset; selection.focus.offset = newOffset;
} }
}); });
} else { } else {
// 普通模式下使用VariableNode // In normal mode, use VariableNode
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion }); editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
} }
setShowSuggestions(false); setShowSuggestions(false);
}; };
// Group suggestions by node ID
const groupedSuggestions = options.reduce((groups: Record<string, any[]>, suggestion) => { const groupedSuggestions = options.reduce((groups: Record<string, any[]>, suggestion) => {
const { nodeData } = suggestion const { nodeData } = suggestion
const nodeId = nodeData.id as string; const nodeId = nodeData.id as string;
@@ -126,6 +150,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
return groups; return groups;
}, {}); }, {});
// Handle Enter key to select suggestion
useEffect(() => { useEffect(() => {
if (!showSuggestions) return; if (!showSuggestions) return;
@@ -148,11 +173,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
); );
}, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]); }, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]);
// Handle keyboard navigation (Arrow Up/Down, Escape)
useEffect(() => { useEffect(() => {
if (!showSuggestions) return; if (!showSuggestions) return;
const allOptions = Object.values(groupedSuggestions).flat(); const allOptions = Object.values(groupedSuggestions).flat();
// Navigate down through suggestions, skip disabled items
const unregisterArrowDown = editor.registerCommand( const unregisterArrowDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND, KEY_ARROW_DOWN_COMMAND,
(event) => { (event) => {
@@ -172,6 +199,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
COMMAND_PRIORITY_HIGH COMMAND_PRIORITY_HIGH
); );
// Navigate up through suggestions, skip disabled items
const unregisterArrowUp = editor.registerCommand( const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND, KEY_ARROW_UP_COMMAND,
(event) => { (event) => {
@@ -191,6 +219,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
COMMAND_PRIORITY_HIGH COMMAND_PRIORITY_HIGH
); );
// Close suggestions on Escape key
const unregisterEscape = editor.registerCommand( const unregisterEscape = editor.registerCommand(
KEY_ESCAPE_COMMAND, KEY_ESCAPE_COMMAND,
(event) => { (event) => {
@@ -239,7 +268,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
return ( return (
<div key={nodeId}> <div key={nodeId}>
{/* Divider between groups */}
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />} {groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
{/* Group header with node name */}
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}> <div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
{nodeName} {nodeName}
</div> </div>

View File

@@ -1,39 +1,64 @@
/*
* @Author: ZhaoYing
* @Date: 2026-01-20 10:42:13
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 10:12:10
*/
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 { $setSelection } from 'lexical';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
export default function BlurPlugin() { // Plugin to handle blur events and close autocomplete when clicking outside
export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(() => { useEffect(() => {
// Close autocomplete when clicking outside the popup
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target?.closest('[data-autocomplete-popup="true"]')) {
return;
}
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
};
document.addEventListener('mousedown', handleClickOutside);
return editor.registerRootListener((rootElement) => { return editor.registerRootListener((rootElement) => {
if (rootElement) { if (rootElement) {
const handleBlur = (e: FocusEvent) => { const handleBlur = (e: FocusEvent) => {
// 检查是否点击了自动完成弹窗 if (enableJinja2) {
const target = e.target as HTMLElement; // Check if autocomplete popup was clicked
console.log('target', target) const target = e.target as HTMLElement;
if (target?.closest('[data-autocomplete-popup="true"]')) { if (target?.closest('[data-autocomplete-popup="true"]')) {
return; 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);
});
} }
// 检查是否是粘贴操作导致的焦点变化
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || relatedTarget === document.body) {
return;
}
editor.update(() => {
$setSelection(null);
});
}; };
rootElement.addEventListener('blur', handleBlur); rootElement.addEventListener('blur', handleBlur);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside);
rootElement.removeEventListener('blur', handleBlur); rootElement.removeEventListener('blur', handleBlur);
}; };
} }
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}); });
}, [editor]); }, [editor, enableJinja2]);
return null; return null;
} }

View File

@@ -49,7 +49,7 @@ const EditableTable: FC<EditableTableProps> = ({
const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => { const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => {
const hasType = typeOptions.length > 0; const hasType = typeOptions.length > 0;
const cellClassName="rb:p-1!" const cellClassName="rb:p-1!"
const contentClassName ="rb:w-[108px]! rb:text-[12px]!" const contentClassName ="rb:w-[108px]! rb:text-[12px]! rb:overflow-hidden!"
return [ return [
{ {