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
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:
95
src/editor/README.md
Normal file
95
src/editor/README.md
Normal 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
|
||||
51
src/editor/RichTextEditor.tsx
Normal file
51
src/editor/RichTextEditor.tsx
Normal 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
1
src/editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './RichTextEditor';
|
||||
230
src/editor/plugins/ToolbarPlugin.tsx
Normal file
230
src/editor/plugins/ToolbarPlugin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
src/editor/styles/editor.css
Normal file
148
src/editor/styles/editor.css
Normal 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;
|
||||
}
|
||||
130
src/editor/styles/toolbar.css
Normal file
130
src/editor/styles/toolbar.css
Normal 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;
|
||||
}
|
||||
33
src/editor/themes/EditorTheme.ts
Normal file
33
src/editor/themes/EditorTheme.ts
Normal 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;
|
||||
50
src/editor/ui/DropdownColorPicker.css
Normal file
50
src/editor/ui/DropdownColorPicker.css
Normal 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;
|
||||
}
|
||||
87
src/editor/ui/DropdownColorPicker.tsx
Normal file
87
src/editor/ui/DropdownColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
32
src/pages/EditorDemo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user