fix(web): Editor input type add blur event
This commit is contained in:
@@ -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');
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user