feat(web): workflow support lexical editor

This commit is contained in:
zhaoying
2025-12-23 16:22:51 +08:00
parent 7d40d06b69
commit 26263bdcf0
11 changed files with 607 additions and 54 deletions

View File

@@ -0,0 +1,86 @@
import { type FC, useState } from 'react';
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 { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
import InitialValuePlugin from './plugin/InitialValuePlugin';
interface LexicalEditorProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
suggestions: Suggestion[];
}
const theme = {
paragraph: 'editor-paragraph',
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
},
};
const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...",
value = "",
onChange,
suggestions,
}) => {
const [count, setCount] = useState(0);
const initialConfig = {
namespace: 'AutocompleteEditor',
theme,
onError: (error: Error) => {
console.error(error);
},
};
return (
<LexicalComposer initialConfig={initialConfig}>
<div style={{ position: 'relative' }}>
<RichTextPlugin
contentEditable={
<ContentEditable
style={{
minHeight: '60px',
padding: '0',
border: 'none',
outline: 'none',
resize: 'none',
fontSize: '14px',
lineHeight: '20px',
}}
/>
}
placeholder={
<div
style={{
position: 'absolute',
top: '0',
left: '0',
color: '#5B6167',
fontSize: '14px',
lineHeight: '20px',
pointerEvents: 'none',
}}
>
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<AutoFocusPlugin />
<AutocompletePlugin suggestions={suggestions} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} />
</div>
</LexicalComposer>
);
};
export default Editor;

View File

@@ -0,0 +1,113 @@
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;
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useState, type FC } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createTextNode, $createParagraphNode, $setSelection, $createRangeSelection, $getSelection } from 'lexical';
import type { NodeProperties } from '../../../types'
export interface Suggestion {
key: string;
label: string;
type: string;
dataType: string;
value: string;
nodeData: NodeProperties
}
const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => {
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
const text = root.getTextContent();
const shouldShow = text.includes('/');
setShowSuggestions(shouldShow);
if (shouldShow) {
const selection = $getSelection();
if (selection) {
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
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;
}
if (left < 10) {
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;
}
}
setPopupPosition({ top, left });
}
}
}
});
});
}, [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);
}
});
setShowSuggestions(false);
};
if (!showSuggestions) return null;
// Group suggestions by node name
const groupedSuggestions = suggestions.reduce((groups: Record<string, any[]>, suggestion) => {
const { nodeData } = suggestion
const nodeName = (nodeData.name || nodeData.id) as string;
if (!groups[nodeName]) {
groups[nodeName] = [];
}
groups[nodeName].push(suggestion);
return groups;
}, {});
return (
<div
style={{
position: 'fixed',
top: popupPosition.top,
left: popupPosition.left,
zIndex: 1000,
background: 'white',
border: '1px solid #d9d9d9',
borderRadius: '6px',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
minWidth: '280px',
maxHeight: '200px',
overflowY: 'auto',
transform: 'translateY(-100%)',
}}
>
{Object.entries(groupedSuggestions).map(([nodeName, nodeOptions], groupIndex) => (
<div key={nodeName}>
{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>
);
})}
</div>
))}
</div>
);
}
export default AutocompletePlugin

View File

@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { $getRoot } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
setCount(textContent.length);
onChange?.(textContent);
});
});
}, [editor, setCount, onChange]);
return null;
}
export default CharacterCountPlugin

View File

@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
interface InitialValuePluginProps {
value: string;
}
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (value) {
editor.update(() => {
const root = $getRoot();
if (root.getTextContent() === '') {
root.clear();
const paragraph = $createParagraphNode();
paragraph.append($createTextNode(value));
root.append(paragraph);
}
});
}
}, [editor, value]);
return null;
};
export default InitialValuePlugin;