feat: Implement a rich text editor using Lexical
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 14s

- Added RichTextEditor component with basic formatting features.
- Integrated toolbar with undo/redo, text formatting, and color pickers.
- Created EditorTheme for styling the editor components.
- Added styles for editor and toolbar.
- Introduced DropdownColorPicker for color selection.
- Updated package.json to include Lexical dependencies.
- Created EditorDemo page to showcase the rich text editor.
- Added README documentation for the editor's features and usage.
This commit is contained in:
2025-10-22 10:20:47 +08:00
parent 6dbb6ff7fb
commit f29f53dec6
13 changed files with 1306 additions and 0 deletions

95
src/editor/README.md Normal file
View File

@@ -0,0 +1,95 @@
# Rich Text Editor
A basic rich text editor built with Lexical, ported from the lexical-playground project.
## Features
### Toolbar Controls
1. **Undo/Redo** - Navigate through editing history
2. **Text Size** - Select from multiple font sizes (10px - 36px)
3. **Bold** - Make text bold
4. **Italic** - Italicize text
5. **Underline** - Underline text
6. **Strikethrough** - Strike through text
7. **Code** - Format text as inline code
8. **Text Color** - Choose from 30 predefined colors
9. **Background Color** - Choose background color for text
### Additional Features
- Quote blocks
- Code blocks (without syntax highlighting)
- Lists (bullet and numbered)
- Headings (H1, H2, H3)
## Usage
### Basic Usage
```tsx
import RichTextEditor from './editor/RichTextEditor';
function MyComponent() {
return <RichTextEditor />;
}
```
### Accessing the Demo
Navigate to `/editor` to see the editor in action.
## Files Structure
```
src/editor/
├── RichTextEditor.tsx # Main editor component
├── index.ts # Export file
├── plugins/
│ └── ToolbarPlugin.tsx # Toolbar with formatting controls
├── ui/
│ ├── DropdownColorPicker.tsx # Color picker component
│ └── DropdownColorPicker.css # Color picker styles
├── themes/
│ └── EditorTheme.ts # Editor theme configuration
└── styles/
├── editor.css # Editor styles
└── toolbar.css # Toolbar styles
```
## Dependencies
- `lexical` - Core editor framework
- `@lexical/react` - React bindings
- `@lexical/code` - Code block support
- `@lexical/list` - List support
- `@lexical/rich-text` - Rich text features (headings, quotes)
- `@lexical/selection` - Selection utilities
- `@lexical/utils` - Utility functions
## Customization
### Theme
Edit `src/editor/themes/EditorTheme.ts` to customize the editor's appearance.
### Toolbar
Modify `src/editor/plugins/ToolbarPlugin.tsx` to add or remove toolbar buttons.
### Styles
- `src/editor/styles/editor.css` - Editor content styles
- `src/editor/styles/toolbar.css` - Toolbar styles
## Future Enhancements
Possible additions (not currently implemented):
- Syntax highlighting for code blocks
- Links
- Images
- Tables
- Alignment controls
- More list types (checklists)
- Markdown shortcuts

View File

@@ -0,0 +1,51 @@
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 { HeadingNode, QuoteNode } from '@lexical/rich-text';
import { CodeNode } from '@lexical/code';
import { ListItemNode, ListNode } from '@lexical/list';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import editorTheme from './themes/EditorTheme';
import './styles/editor.css';
const editorConfig = {
namespace: 'CiallooEditor',
theme: editorTheme,
onError(error: Error) {
console.error(error);
},
nodes: [
HeadingNode,
QuoteNode,
CodeNode,
ListNode,
ListItemNode,
],
};
export default function RichTextEditor() {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<RichTextPlugin
contentEditable={
<ContentEditable className="editor-input" />
}
placeholder={
<div className="editor-placeholder">Enter some text...</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<HistoryPlugin />
<ListPlugin />
</div>
</div>
</LexicalComposer>
);
}

1
src/editor/index.ts Normal file
View File

@@ -0,0 +1 @@
export { default } from './RichTextEditor';

View File

@@ -0,0 +1,230 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
CAN_REDO_COMMAND,
CAN_UNDO_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
SELECTION_CHANGE_COMMAND,
FORMAT_TEXT_COMMAND,
COMMAND_PRIORITY_CRITICAL,
$getSelection,
$isRangeSelection,
} from 'lexical';
import type { TextFormatType } from 'lexical';
import { $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import '../styles/toolbar.css';
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontColor, setFontColor] = useState('#000');
const [bgColor, setBgColor] = useState('#fff');
const [fontSize, setFontSize] = useState('15px');
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
// Update color
setFontColor(
$getSelectionStyleValueForProperty(selection, 'color', '#000')
);
setBgColor(
$getSelectionStyleValueForProperty(selection, 'background-color', '#fff')
);
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '15px')
);
}
}, [editor]);
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateToolbar();
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor, updateToolbar]);
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
});
}, [editor, updateToolbar]);
useEffect(() => {
return editor.registerCommand(
CAN_UNDO_COMMAND,
(payload) => {
setCanUndo(payload);
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor]);
useEffect(() => {
return editor.registerCommand(
CAN_REDO_COMMAND,
(payload) => {
setCanRedo(payload);
return false;
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor]);
const formatText = (format: TextFormatType) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
};
const onFontColorSelect = useCallback(
(value: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { color: value });
}
});
},
[editor]
);
const onBgColorSelect = useCallback(
(value: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { 'background-color': value });
}
});
},
[editor]
);
const onFontSizeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$patchStyleText(selection, { 'font-size': value });
}
});
},
[editor]
);
return (
<div className="toolbar" ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
className="toolbar-item spaced"
aria-label="Undo">
<i className="format undo" />
</button>
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
className="toolbar-item"
aria-label="Redo">
<i className="format redo" />
</button>
<div className="divider" />
<select
className="toolbar-item block-controls"
value={fontSize}
onChange={onFontSizeChange}>
<option value="10px">10px</option>
<option value="11px">11px</option>
<option value="12px">12px</option>
<option value="13px">13px</option>
<option value="14px">14px</option>
<option value="15px">15px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="30px">30px</option>
<option value="36px">36px</option>
</select>
<div className="divider" />
<button
onClick={() => formatText('bold')}
className={'toolbar-item spaced ' + (isBold ? 'active' : '')}
aria-label="Format Bold">
<i className="format bold" />
</button>
<button
onClick={() => formatText('italic')}
className={'toolbar-item spaced ' + (isItalic ? 'active' : '')}
aria-label="Format Italics">
<i className="format italic" />
</button>
<button
onClick={() => formatText('underline')}
className={'toolbar-item spaced ' + (isUnderline ? 'active' : '')}
aria-label="Format Underline">
<i className="format underline" />
</button>
<button
onClick={() => formatText('strikethrough')}
className={'toolbar-item spaced ' + (isStrikethrough ? 'active' : '')}
aria-label="Format Strikethrough">
<i className="format strikethrough" />
</button>
<button
onClick={() => formatText('code')}
className={'toolbar-item spaced ' + (isCode ? 'active' : '')}
aria-label="Insert Code">
<i className="format code" />
</button>
<div className="divider" />
<DropdownColorPicker
buttonClassName="toolbar-item color-picker"
buttonAriaLabel="Formatting text color"
buttonIconClassName="icon font-color"
color={fontColor}
onChange={onFontColorSelect}
title="text color"
/>
<DropdownColorPicker
buttonClassName="toolbar-item color-picker"
buttonAriaLabel="Formatting background color"
buttonIconClassName="icon bg-color"
color={bgColor}
onChange={onBgColorSelect}
title="bg color"
/>
</div>
);
}

View File

@@ -0,0 +1,148 @@
.editor-container {
margin: 20px auto;
border-radius: 8px;
max-width: 1100px;
color: #000;
position: relative;
line-height: 1.7;
font-weight: 400;
text-align: left;
border: 1px solid #e0e0e0;
background: #fff;
}
.editor-inner {
background: #fff;
position: relative;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.editor-input {
min-height: 300px;
resize: vertical;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 20px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
top: 15px;
left: 20px;
font-size: 15px;
user-select: none;
display: inline-block;
pointer-events: none;
}
.editor-paragraph {
margin: 0;
margin-bottom: 8px;
position: relative;
}
.editor-paragraph:last-child {
margin-bottom: 0;
}
.editor-heading-h1 {
font-size: 2em;
font-weight: 700;
margin: 0;
margin-bottom: 12px;
padding: 0;
}
.editor-heading-h2 {
font-size: 1.5em;
font-weight: 700;
margin: 0;
margin-bottom: 10px;
padding: 0;
}
.editor-heading-h3 {
font-size: 1.25em;
font-weight: 700;
margin: 0;
margin-bottom: 8px;
padding: 0;
}
.editor-quote {
margin: 0;
margin-left: 20px;
margin-bottom: 10px;
font-size: 15px;
color: #666;
border-left: 4px solid #ccc;
padding-left: 16px;
}
.editor-code {
background-color: #f4f4f4;
font-family: Menlo, Consolas, Monaco, monospace;
display: block;
padding: 8px 12px;
line-height: 1.53;
font-size: 13px;
margin: 0;
margin-top: 8px;
margin-bottom: 8px;
overflow-x: auto;
position: relative;
tab-size: 2;
border-radius: 4px;
}
.editor-text-bold {
font-weight: bold;
}
.editor-text-italic {
font-style: italic;
}
.editor-text-underline {
text-decoration: underline;
}
.editor-text-strikethrough {
text-decoration: line-through;
}
.editor-text-code {
background-color: #f4f4f4;
padding: 1px 4px;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 90%;
border-radius: 3px;
}
.editor-list-ol {
padding: 0;
margin: 0;
margin-left: 20px;
}
.editor-list-ul {
padding: 0;
margin: 0;
margin-left: 20px;
}
.editor-listitem {
margin: 4px 0;
}
.editor-nested-listitem {
list-style-type: none;
}

View File

@@ -0,0 +1,130 @@
.toolbar {
display: flex;
background: #fff;
padding: 8px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
vertical-align: middle;
border-bottom: 1px solid #e0e0e0;
gap: 4px;
flex-wrap: wrap;
align-items: center;
}
.toolbar button {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
vertical-align: middle;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.toolbar button:hover:not([disabled]) {
background-color: #f0f0f0;
}
.toolbar button:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.toolbar button.active {
background-color: rgba(24, 144, 255, 0.1);
}
.toolbar-item {
border: 0;
display: flex;
background: none;
border-radius: 4px;
padding: 6px;
cursor: pointer;
vertical-align: middle;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
}
.toolbar-item.spaced {
margin-right: 2px;
}
.toolbar-item.block-controls {
background: none;
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
min-width: 80px;
}
.toolbar-item i.format {
background-size: contain;
display: inline-block;
height: 18px;
width: 18px;
vertical-align: -0.25em;
opacity: 0.7;
}
.toolbar-item.active i.format,
.toolbar-item:hover:not([disabled]) i.format {
opacity: 1;
}
.divider {
width: 1px;
background-color: #e0e0e0;
margin: 0 4px;
height: 24px;
}
i.format.undo {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>');
}
i.format.redo {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 019-9 9 9 0 016 2.3l3 2.7"/></svg>');
}
i.format.bold {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>');
}
i.format.italic {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>');
}
i.format.underline {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>');
}
i.format.strikethrough {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.3 5.3c-.94-.94-2.2-1.3-3.3-1.3H10a4 4 0 0 0 0 8"/><path d="M14 12a4 4 0 0 1 0 8h-4"/><line x1="4" y1="12" x2="20" y2="12"/></svg>');
}
i.format.code {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>');
}
i.icon.font-color {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h16"/><path d="m6 16 6-12 6 12"/><path d="M8 12h8"/></svg>');
display: inline-block;
height: 18px;
width: 18px;
background-size: contain;
}
i.icon.bg-color {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 14c0-2.5-2-4-4-4H8c-2 0-4 1.5-4 4v6h16v-6Z"/><path d="M4 14V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8"/></svg>');
display: inline-block;
height: 18px;
width: 18px;
background-size: contain;
}

View File

@@ -0,0 +1,33 @@
import type { EditorThemeClasses } from 'lexical';
const theme: EditorThemeClasses = {
paragraph: 'editor-paragraph',
quote: 'editor-quote',
heading: {
h1: 'editor-heading-h1',
h2: 'editor-heading-h2',
h3: 'editor-heading-h3',
h4: 'editor-heading-h4',
h5: 'editor-heading-h5',
h6: 'editor-heading-h6',
},
list: {
nested: {
listitem: 'editor-nested-listitem',
},
ol: 'editor-list-ol',
ul: 'editor-list-ul',
listitem: 'editor-listitem',
},
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
underline: 'editor-text-underline',
strikethrough: 'editor-text-strikethrough',
code: 'editor-text-code',
},
code: 'editor-code',
codeHighlight: {},
};
export default theme;

View File

@@ -0,0 +1,50 @@
.color-picker-wrapper {
position: relative;
}
.color-preview {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #ccc;
margin-left: 4px;
vertical-align: middle;
}
.color-picker-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.color-picker-basic-color {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
}
.color-picker-basic-color button {
width: 24px;
height: 24px;
border: 1px solid #ccc;
border-radius: 2px;
cursor: pointer;
padding: 0;
}
.color-picker-basic-color button:hover {
border-color: #333;
}
.color-picker-basic-color button.active {
border: 2px solid #1890ff;
border-radius: 4px;
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react';
import './DropdownColorPicker.css';
const basicColors = [
'#000000', '#ffffff', '#888888', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#00ffff', '#ff00ff', '#c0c0c0', '#808080', '#800000',
'#808000', '#008000', '#800080', '#008080', '#000080', '#ffa500',
'#a52a2a', '#dc143c', '#ff1493', '#ff69b4', '#ffd700', '#adff2f',
'#00fa9a', '#00ced1', '#1e90ff', '#9370db', '#ff6347', '#40e0d0',
];
type Props = {
buttonClassName: string;
buttonAriaLabel?: string;
buttonIconClassName?: string;
color: string;
onChange?: (color: string) => void;
title?: string;
};
export default function DropdownColorPicker({
buttonClassName,
buttonAriaLabel,
buttonIconClassName,
color,
onChange,
title,
}: Props) {
const [showPicker, setShowPicker] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setShowPicker(false);
}
};
if (showPicker) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showPicker]);
return (
<div className="color-picker-wrapper">
<button
ref={buttonRef}
type="button"
className={buttonClassName}
onClick={() => setShowPicker(!showPicker)}
aria-label={buttonAriaLabel || title}
style={{ position: 'relative' }}>
{buttonIconClassName && <i className={buttonIconClassName} />}
<span
className="color-preview"
style={{ backgroundColor: color }}
/>
</button>
{showPicker && (
<div ref={dropdownRef} className="color-picker-dropdown">
<div className="color-picker-basic-color">
{basicColors.map((basicColor) => (
<button
key={basicColor}
className={basicColor === color ? ' active' : ''}
style={{ backgroundColor: basicColor }}
onClick={() => {
onChange?.(basicColor);
setShowPicker(false);
}}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ import Blog from './pages/Blog.tsx'
import Servers from './pages/Servers.tsx'
import Forum from './pages/Forum.tsx'
import AuthCallback from './pages/AuthCallback.tsx'
import EditorDemo from './pages/EditorDemo.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -30,6 +31,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="/servers" element={<Servers />} />
<Route path="/forum" element={<Forum />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/editor" element={<EditorDemo />} />
</Routes>
</Router>
</ServerProvider>

32
src/pages/EditorDemo.tsx Normal file
View File

@@ -0,0 +1,32 @@
import RichTextEditor from '../editor/RichTextEditor';
import '../editor/styles/editor.css';
import '../editor/styles/toolbar.css';
export default function EditorDemo() {
return (
<div style={{
padding: '40px 20px',
maxWidth: '1200px',
margin: '0 auto',
minHeight: '100vh'
}}>
<h1 style={{
marginBottom: '20px',
fontSize: '2em',
fontWeight: 'bold'
}}>
Rich Text Editor Demo
</h1>
<p style={{
marginBottom: '30px',
color: '#666',
fontSize: '1.1em'
}}>
A basic rich text editor with formatting toolbar featuring:
undo/redo, text size, bold, italic, underline, strikethrough,
code blocks, text color, and background color.
</p>
<RichTextEditor />
</div>
);
}