Merge pull request #777 from SuanmoSuanyangTechnology/hotfix/v0.2.9

fix(web): jinja2 editor
This commit is contained in:
Ke Sun
2026-04-02 15:39:21 +08:00
committed by GitHub
3 changed files with 228 additions and 153 deletions

View File

@@ -0,0 +1,186 @@
/*
* @Author: ZhaoYing
* @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 15:15:36
*/
import { type FC, useEffect, useMemo } 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 { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin';
import CharacterCountPlugin from './plugin/CharacterCountPlugin';
import InitialValuePlugin from './plugin/InitialValuePlugin';
import CommandPlugin from './plugin/CommandPlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin';
const jinja2Theme = {
paragraph: 'editor-paragraph',
code: 'jinja2-expression',
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
code: 'jinja2-inline',
},
};
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: jinja2Theme,
nodes: [],
onError: (error: Error) => console.error(error),
};
const STYLE_ID = 'code-editor-styles';
const JINJA2_STYLES = `
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph { margin: 0; }
.editor-with-line-numbers { display: flex; }
.line-numbers {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
text-align: right;
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers > div { min-height: 20px; display: flex; align-items: flex-start; }
.editor-content-wrapper { flex: 1; }
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p { margin: 0; min-height: 20px; }
`;
export interface Jinja2EditorProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
options?: Suggestion[];
variant?: 'outlined' | 'borderless';
height?: number;
size?: 'default' | 'small';
className?: string;
}
const Jinja2Editor: FC<Jinja2EditorProps> = ({
placeholder = '请输入内容...',
value = '',
onChange,
options = [],
variant = 'borderless',
size = 'default',
height,
className,
}) => {
useEffect(() => {
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = JINJA2_STYLES;
document.head.appendChild(style);
}
}, []);
const minheight = useMemo(
() => `${height ?? (size === 'small' ? 60 : 120)}px`,
[height, size],
);
const fontSize = size === 'small' ? '12px' : '14px';
const lineHeight = useMemo(
() => `${height ? height - 10 : size === 'small' ? 16 : 20}px`,
[height, size],
);
const placeHolderMinheight = `${height ? 16 : size === 'small' ? 16 : 30}px`;
return (
<LexicalComposer initialConfig={initialConfig}>
<div style={{ position: 'relative' }} className={className}>
<RichTextPlugin
contentEditable={
<div
className="editor-with-line-numbers"
style={{
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
borderRadius: '6px',
minHeight: minheight,
}}
>
<div className="line-numbers">
<div>1</div>
</div>
<div className="editor-content-wrapper">
<ContentEditable
className="editor-content-with-numbers"
style={{
minHeight: minheight,
padding: variant === 'borderless' ? '0' : '4px 0',
outline: 'none',
resize: 'none',
fontSize,
lineHeight,
border: 'none',
}}
/>
</div>
</div>
}
placeholder={
<div
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: '4px',
left: '16px',
color: '#A8A9AA',
fontSize,
lineHeight: placeHolderMinheight,
pointerEvents: 'none',
}}
>
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<CommandPlugin />
<Jinja2HighlightPlugin />
<LineNumberPlugin />
<AutocompletePlugin options={options} enableJinja2 />
<CharacterCountPlugin setCount={() => {}} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers />
<BlurPlugin enableJinja2 />
</div>
</LexicalComposer>
);
};
export default Jinja2Editor;

View File

@@ -4,26 +4,20 @@
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 10:58:47
*/
import { type FC, useState, useEffect, useMemo } from 'react';
import { type FC, useState, useMemo } 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 { HeadingNode, QuoteNode } from '@lexical/rich-text';
// import { ListItemNode, ListNode } from '@lexical/list';
// import { LinkNode } from '@lexical/link';
// import { CodeNode } from '@lexical/code';
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
import InitialValuePlugin from './plugin/InitialValuePlugin';
import CommandPlugin from './plugin/CommandPlugin';
import Jinja2HighlightPlugin from './plugin/Jinja2HighlightPlugin';
import LineNumberPlugin from './plugin/LineNumberPlugin';
import BlurPlugin from './plugin/BlurPlugin';
import { VariableNode } from './nodes/VariableNode'
import Jinja2Editor from './Jinja2Editor';
// Props interface for Lexical Editor component
export interface LexicalEditorProps {
@@ -50,16 +44,6 @@ const theme = {
},
};
// Theme with Jinja2 syntax highlighting
const jinja2Theme = {
...theme,
code: 'jinja2-expression',
text: {
...theme.text,
code: 'jinja2-inline',
},
};
// Main Lexical Editor component
const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...",
@@ -74,97 +58,27 @@ const Editor: FC<LexicalEditorProps> =({
className
}) => {
const [_count, setCount] = useState(0);
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');
setEnableLineNumbers(needsLineNumbers);
if (needsLineNumbers) {
const styleId = 'code-editor-styles';
let existingStyle = document.getElementById(styleId);
if (!existingStyle) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph {
margin: 0;
}
.editor-paragraph:has-text('{') .editor-text,
.editor-paragraph:has-text('[') .editor-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
}
.editor-with-line-numbers {
display: flex;
}
.line-numbers {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 16px;
padding: 4px 8px;
text-align: right;
user-select: none;
display: flex;
flex-direction: column;
}
.line-numbers > div {
min-height: 20px;
display: flex;
align-items: flex-start;
}
.editor-content-wrapper {
flex: 1;
}
.editor-content-with-numbers {
white-space: pre-wrap;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.editor-content-with-numbers p {
margin: 0;
min-height: 20px;
}
`;
document.head.appendChild(style);
}
}
}, [language])
if (language === 'jinja2') {
return (
<Jinja2Editor
placeholder={placeholder}
value={value}
onChange={onChange}
options={options}
variant={variant}
size={size}
height={height}
className={className}
/>
);
}
// Lexical editor configuration
const initialConfig = {
namespace: 'AutocompleteEditor',
theme: enableJinja2 ? jinja2Theme : theme,
nodes: enableJinja2 ? [
// When Jinja2 is enabled, use plain text instead of VariableNode
] : [
// HeadingNode,
// QuoteNode,
// ListItemNode,
// ListNode,
// LinkNode,
// CodeNode,
VariableNode,
],
theme,
nodes: [VariableNode],
onError: (error: Error) => {
console.error(error);
},
@@ -198,54 +112,26 @@ const Editor: FC<LexicalEditorProps> =({
<div style={{ position: 'relative' }} className={className}>
<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',
<ContentEditable
style={{
minHeight: minheight,
}}>
<div className="line-numbers">
<div>1</div>
</div>
<div className="editor-content-wrapper">
<ContentEditable
className="editor-content-with-numbers"
style={{
minHeight: minheight,
padding: variant === 'borderless' ? '0' : '4px 0',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
border: 'none',
}}
/>
</div>
</div>
) : (
// Standard editor without line numbers
<ContentEditable
style={{
minHeight: minheight,
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
borderRadius: '8px',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
}}
/>
)
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
borderRadius: '8px',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
}}
/>
}
placeholder={
<div
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: enableLineNumbers ? '4px' : variant === 'borderless' ? '0' : '6px',
left: enableLineNumbers ? '16px' : (variant === 'borderless' ? '0' : '11px'),
top: variant === 'borderless' ? '0' : '6px',
left: variant === 'borderless' ? '0' : '11px',
color: '#A8A9AA',
fontSize: fontSize,
lineHeight: placeHolderMinheight,
@@ -257,15 +143,12 @@ const Editor: FC<LexicalEditorProps> =({
}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* Editor plugins */}
<HistoryPlugin />
<CommandPlugin />
{language === 'jinja2' && <Jinja2HighlightPlugin />}
{enableLineNumbers && <LineNumberPlugin />}
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<AutocompletePlugin options={options} enableJinja2={false} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
<BlurPlugin enableJinja2={enableJinja2} />
<InitialValuePlugin value={value} options={options} enableLineNumbers={false} />
<BlurPlugin enableJinja2={false} />
</div>
</LexicalComposer>
);

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { $getRoot, $isParagraphNode } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
@@ -6,9 +6,15 @@ import { $isVariableNode } from '../nodes/VariableNode';
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
const [editor] = useLexicalComposerContext();
const isReadyRef = useRef(false);
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
return editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has('programmatic')) {
isReadyRef.current = true;
return;
}
if (!isReadyRef.current) return;
editorState.read(() => {
const root = $getRoot();
let serializedContent = '';