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 type { Suggestion } from '../plugin/AutocompletePlugin';
// Payload interface for inserting variable command
export interface InsertVariableCommandPayload {
data: Suggestion;
}
// Command to insert a variable into the editor
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 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 { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
@@ -19,6 +25,7 @@ import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin';
import { VariableNode } from './nodes/VariableNode'
// Props interface for Lexical Editor component
export interface LexicalEditorProps {
placeholder?: string;
value?: string;
@@ -34,6 +41,7 @@ export interface LexicalEditorProps {
className?: string;
}
// Default theme for editor
const theme = {
paragraph: 'editor-paragraph',
text: {
@@ -42,6 +50,7 @@ const theme = {
},
};
// Theme with Jinja2 syntax highlighting
const jinja2Theme = {
...theme,
code: 'jinja2-expression',
@@ -51,7 +60,8 @@ const jinja2Theme = {
},
};
const Editor: FC<LexicalEditorProps> =({
// Main Lexical Editor component
const Editor: FC<LexicalEditorProps> =(({
placeholder = "请输入内容...",
value = "",
onChange,
@@ -67,6 +77,7 @@ const Editor: FC<LexicalEditorProps> =({
const [enableJinja2, setEnableJinja2] = useState(false)
const [enableLineNumbers, setEnableLineNumbers] = useState(false)
// Setup Jinja2 mode and inject styles when language changes
useEffect(() => {
const needsLineNumbers = language === 'jinja2';
setEnableJinja2(language === 'jinja2');
@@ -139,11 +150,12 @@ const Editor: FC<LexicalEditorProps> =({
}
}, [language])
// Lexical editor configuration
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: enableJinja2 ? jinja2Theme : theme,
nodes: enableJinja2 ? [
// 当启用jinja2时不使用VariableNode,使用普通文本
// When Jinja2 is enabled, use plain text instead of VariableNode
] : [
// HeadingNode,
// QuoteNode,
@@ -157,18 +169,26 @@ const Editor: FC<LexicalEditorProps> =({
console.error(error);
},
};
// Calculate minimum height based on type and size
const minheight = useMemo(() => {
if (type === 'input') {
return `${height ? height : size === 'small' ? 28 : 30}px`
}
return `${height ? height : size === 'small' ? 60 : 120}px`
}, [type, size, height])
// Calculate font size based on size prop
const fontSize = useMemo(() => {
return `${size === 'small' ? 12 : 14}px`
}, [size])
// Calculate line height based on size prop
const lineHeight = useMemo(() => {
return `${height ? height : size === 'small' ? 16 : 20}px`
}, [size])
// Calculate placeholder minimum height
const placeHolderMinheight = useMemo(() => {
return `${height ? height : size === 'small' ? 16 : 30}px`
}, [type, size, height])
@@ -179,6 +199,7 @@ const Editor: FC<LexicalEditorProps> =({
<RichTextPlugin
contentEditable={
enableLineNumbers ? (
// Editor with line numbers for Jinja2 mode
<div className="editor-with-line-numbers" style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
@@ -203,6 +224,7 @@ const Editor: FC<LexicalEditorProps> =({
</div>
</div>
) : (
// Standard editor without line numbers
<ContentEditable
style={{
minHeight: minheight,
@@ -235,6 +257,7 @@ const Editor: FC<LexicalEditorProps> =({
}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* Editor plugins */}
<HistoryPlugin />
<CommandPlugin />
{language === 'jinja2' && <Jinja2HighlightPlugin />}
@@ -242,10 +265,10 @@ const Editor: FC<LexicalEditorProps> =({
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
{enableJinja2 && <BlurPlugin />}
<BlurPlugin enableJinja2={enableJinja2} />
</div>
</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 { 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 { INSERT_VARIABLE_COMMAND } from '../commands';
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { NodeProperties } from '../../../types'
// Suggestion item interface for autocomplete dropdown
export interface Suggestion {
key: string;
label: string;
@@ -13,16 +20,18 @@ export interface Suggestion {
value: string;
group?: string
nodeData: NodeProperties;
isContext?: boolean; // 标记是否为context变量
disabled?: boolean; // 标记是否禁用
isContext?: boolean; // Flag for context variable
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 [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
// Listen to editor updates and show suggestions when '/' is typed
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
@@ -49,6 +58,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
setSelectedIndex(0);
}
// Calculate popup position to keep it within viewport bounds
if (shouldShow) {
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
@@ -84,9 +94,22 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
});
}, [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) => {
if (enableJinja2) {
// 在jinja2模式下,插入{{variable}}格式的文本
// In Jinja2 mode, insert {{variable}} format text
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
@@ -94,7 +117,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
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;
@@ -103,19 +126,20 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
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 {
// 普通模式下使用VariableNode
// In normal mode, use VariableNode
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
}
setShowSuggestions(false);
};
// Group suggestions by node ID
const groupedSuggestions = options.reduce((groups: Record<string, any[]>, suggestion) => {
const { nodeData } = suggestion
const nodeId = nodeData.id as string;
@@ -126,6 +150,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
return groups;
}, {});
// Handle Enter key to select suggestion
useEffect(() => {
if (!showSuggestions) return;
@@ -148,11 +173,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
);
}, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]);
// Handle keyboard navigation (Arrow Up/Down, Escape)
useEffect(() => {
if (!showSuggestions) return;
const allOptions = Object.values(groupedSuggestions).flat();
// Navigate down through suggestions, skip disabled items
const unregisterArrowDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
@@ -172,6 +199,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
COMMAND_PRIORITY_HIGH
);
// Navigate up through suggestions, skip disabled items
const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
@@ -191,6 +219,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
COMMAND_PRIORITY_HIGH
);
// Close suggestions on Escape key
const unregisterEscape = editor.registerCommand(
KEY_ESCAPE_COMMAND,
(event) => {
@@ -239,7 +268,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
return (
<div key={nodeId}>
{/* Divider between groups */}
{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' }}>
{nodeName}
</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 { useEffect } from 'react';
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();
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) => {
if (rootElement) {
const handleBlur = (e: FocusEvent) => {
// 检查是否点击了自动完成弹窗
const target = e.target as HTMLElement;
console.log('target', target)
if (target?.closest('[data-autocomplete-popup="true"]')) {
return;
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);
});
}
// 检查是否是粘贴操作导致的焦点变化
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]);
}, [editor, enableJinja2]);
return null;
}

View File

@@ -49,7 +49,7 @@ const EditableTable: FC<EditableTableProps> = ({
const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => {
const hasType = typeOptions.length > 0;
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 [
{