feat: enhance rich text editor with new features and plugins
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 23s

- Added support for additional Lexical plugins: link, markdown, table, and checklist.
- Implemented Markdown shortcuts and transformers for horizontal rules and tables.
- Updated editor theme and styles to accommodate new elements like tables and links.
- Improved the overall functionality of the rich text editor with new matchers for URLs and emails.
This commit is contained in:
2025-10-22 17:22:45 +08:00
parent f29f53dec6
commit 3491ae339d
7 changed files with 1257 additions and 668 deletions

View File

@@ -4,14 +4,57 @@ 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 { CodeNode, CodeHighlightNode } from '@lexical/code';
import { ListItemNode, ListNode } from '@lexical/list';
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin';
import { HorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode';
import { TableCellNode, TableNode, TableRowNode } from '@lexical/table';
import { TablePlugin } from '@lexical/react/LexicalTablePlugin';
import { LinkNode, AutoLinkNode } from '@lexical/link';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
import editorTheme from './themes/EditorTheme';
import './styles/editor.css';
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
const EMAIL_MATCHER =
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
const MATCHERS = [
(text: string) => {
const match = URL_MATCHER.exec(text);
if (match === null) {
return null;
}
const fullMatch = match[0];
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
};
},
(text: string) => {
const match = EMAIL_MATCHER.exec(text);
if (match === null) {
return null;
}
const fullMatch = match[0];
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: `mailto:${fullMatch}`,
};
},
];
const editorConfig = {
namespace: 'CiallooEditor',
theme: editorTheme,
@@ -22,8 +65,15 @@ const editorConfig = {
HeadingNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
ListNode,
ListItemNode,
HorizontalRuleNode,
TableNode,
TableRowNode,
TableCellNode,
LinkNode,
AutoLinkNode,
],
};
@@ -44,6 +94,11 @@ export default function RichTextEditor() {
/>
<HistoryPlugin />
<ListPlugin />
<CheckListPlugin />
<LinkPlugin />
<AutoLinkPlugin matchers={MATCHERS} />
<TablePlugin />
<MarkdownPlugin />
</div>
</div>
</LexicalComposer>

View File

@@ -0,0 +1,7 @@
import type { JSX } from 'react';
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin';
import { EDITOR_TRANSFORMERS } from './MarkdownTransformers';
export default function MarkdownPlugin(): JSX.Element {
return <MarkdownShortcutPlugin transformers={EDITOR_TRANSFORMERS} />;
}

View File

@@ -0,0 +1,229 @@
import type { ElementTransformer, Transformer } from '@lexical/markdown';
import {
CHECK_LIST,
ELEMENT_TRANSFORMERS,
MULTILINE_ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
} from '@lexical/markdown';
import {
$createHorizontalRuleNode,
$isHorizontalRuleNode,
} from '@lexical/react/LexicalHorizontalRuleNode';
import {
$createTableCellNode,
$createTableNode,
$createTableRowNode,
$isTableCellNode,
$isTableNode,
$isTableRowNode,
TableCellHeaderStates,
type TableCellNode,
type TableNode,
} from '@lexical/table';
import {
$createTextNode,
$isParagraphNode,
$isTextNode,
type LexicalNode,
} from 'lexical';
// Horizontal Rule transformer (---, ***, ___)
export const HR: ElementTransformer = {
dependencies: [],
export: (node: LexicalNode) => {
return $isHorizontalRuleNode(node) ? '***' : null;
},
regExp: /^(---|\*\*\*|___)\s?$/,
replace: (parentNode, _1, _2, isImport) => {
const line = $createHorizontalRuleNode();
if (isImport || parentNode.getNextSibling() != null) {
parentNode.replace(line);
} else {
parentNode.insertBefore(line);
}
line.selectNext();
},
type: 'element',
};
// Table transformer
const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/;
const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/;
const $createTableCell = (textContent: string): TableCellNode => {
const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
const text = $createTextNode(textContent.trim());
cell.append(text);
return cell;
};
const mapToTableCells = (textContent: string): Array<TableCellNode> | null => {
const match = textContent.match(TABLE_ROW_REG_EXP);
if (!match || !match[1]) {
return null;
}
return match[1].split('|').map((text) => $createTableCell(text));
};
function getTableColumnsSize(table: TableNode) {
const row = table.getFirstChild();
return $isTableRowNode(row) ? row.getChildrenSize() : 0;
}
export const TABLE: ElementTransformer = {
dependencies: [],
export: (node: LexicalNode) => {
if (!$isTableNode(node)) {
return null;
}
const output: string[] = [];
for (const row of node.getChildren()) {
const rowOutput = [];
if (!$isTableRowNode(row)) {
continue;
}
let isHeaderRow = false;
for (const cell of row.getChildren()) {
if ($isTableCellNode(cell)) {
const textContent = cell.getTextContent().replace(/\n/g, '\\n').trim();
rowOutput.push(textContent);
if (cell.__headerState === TableCellHeaderStates.ROW) {
isHeaderRow = true;
}
}
}
output.push(`| ${rowOutput.join(' | ')} |`);
if (isHeaderRow) {
output.push(`| ${rowOutput.map((_) => '---').join(' | ')} |`);
}
}
return output.join('\n');
},
regExp: TABLE_ROW_REG_EXP,
replace: (parentNode, _1, match) => {
// Header row divider
if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) {
const table = parentNode.getPreviousSibling();
if (!table || !$isTableNode(table)) {
return;
}
const rows = table.getChildren();
const lastRow = rows[rows.length - 1];
if (!lastRow || !$isTableRowNode(lastRow)) {
return;
}
// Add header state to row cells
lastRow.getChildren().forEach((cell) => {
if (!$isTableCellNode(cell)) {
return;
}
cell.setHeaderStyles(
TableCellHeaderStates.ROW,
TableCellHeaderStates.ROW,
);
});
// Remove divider line
parentNode.remove();
return;
}
const matchCells = mapToTableCells(match[0]);
if (matchCells == null) {
return;
}
const rows = [matchCells];
let sibling = parentNode.getPreviousSibling();
let maxCells = matchCells.length;
while (sibling) {
if (!$isParagraphNode(sibling)) {
break;
}
if (sibling.getChildrenSize() !== 1) {
break;
}
const firstChild = sibling.getFirstChild();
if (!$isTextNode(firstChild)) {
break;
}
const cells = mapToTableCells(firstChild.getTextContent());
if (cells == null) {
break;
}
maxCells = Math.max(maxCells, cells.length);
rows.unshift(cells);
const previousSibling = sibling.getPreviousSibling();
sibling.remove();
sibling = previousSibling;
}
const table = $createTableNode();
for (const cells of rows) {
const tableRow = $createTableRowNode();
table.append(tableRow);
for (let i = 0; i < maxCells; i++) {
tableRow.append(i < cells.length ? cells[i] : $createTableCell(''));
}
}
const previousSibling = parentNode.getPreviousSibling();
if (
$isTableNode(previousSibling) &&
getTableColumnsSize(previousSibling) === maxCells
) {
previousSibling.append(...table.getChildren());
parentNode.remove();
} else {
parentNode.replace(table);
}
table.selectEnd();
},
type: 'element',
};
// Export all transformers for full markdown support
// Includes support for:
// - Headings (# ## ###)
// - Bold (**text** or __text__)
// - Italic (*text* or _text_)
// - Strikethrough (~~text~~)
// - Code (`code`)
// - Links ([text](url))
// - Lists (ordered and unordered)
// - Checkboxes (- [ ] or - [x])
// - Blockquotes (>)
// - Code blocks (```)
// - Horizontal rules (---, ***, ___)
// - Tables (| col1 | col2 |)
export const EDITOR_TRANSFORMERS: Array<Transformer> = [
TABLE,
HR,
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
];

View File

@@ -146,3 +146,103 @@
.editor-nested-listitem {
list-style-type: none;
}
/* Horizontal Rule */
.editor-hr {
border: none;
border-top: 2px solid #ccc;
margin: 15px 0;
}
/* Link */
.editor-link {
color: #0066cc;
text-decoration: none;
cursor: pointer;
}
.editor-link:hover {
text-decoration: underline;
}
/* Table */
.editor-table {
border-collapse: collapse;
border-spacing: 0;
overflow-y: scroll;
overflow-x: scroll;
table-layout: fixed;
width: max-content;
margin: 15px 0;
}
.editor-table-cell {
border: 1px solid #ccc;
min-width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
outline: none;
}
.editor-table-cell-header {
background-color: #f4f4f4;
font-weight: bold;
text-align: left;
}
/* Checklist */
.editor-listitem-checked,
.editor-listitem-unchecked {
position: relative;
margin-left: 8px;
margin-right: 8px;
padding-left: 24px;
padding-right: 24px;
list-style-type: none;
outline: none;
}
.editor-listitem-checked {
text-decoration: line-through;
}
.editor-listitem-unchecked:before,
.editor-listitem-checked:before {
content: '';
width: 16px;
height: 16px;
top: 2px;
left: 0;
cursor: pointer;
display: block;
background-size: cover;
position: absolute;
}
.editor-listitem-unchecked:before {
border: 1px solid #999;
border-radius: 2px;
}
.editor-listitem-checked:before {
border: 1px solid #0066cc;
border-radius: 2px;
background-color: #0066cc;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='white' d='M13.5 2l-7.5 7.5-3.5-3.5-1.5 1.5 5 5 9-9z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
}
.editor-listitem-checked[dir='rtl']:before,
.editor-listitem-unchecked[dir='rtl']:before {
left: auto;
right: 0;
}
.editor-listitem-checked[dir='rtl'],
.editor-listitem-unchecked[dir='rtl'] {
padding-left: 24px;
padding-right: 24px;
}

View File

@@ -18,6 +18,8 @@ const theme: EditorThemeClasses = {
ol: 'editor-list-ol',
ul: 'editor-list-ul',
listitem: 'editor-listitem',
listitemChecked: 'editor-listitem-checked',
listitemUnchecked: 'editor-listitem-unchecked',
},
text: {
bold: 'editor-text-bold',
@@ -28,6 +30,11 @@ const theme: EditorThemeClasses = {
},
code: 'editor-code',
codeHighlight: {},
link: 'editor-link',
table: 'editor-table',
tableCell: 'editor-table-cell',
tableCellHeader: 'editor-table-cell-header',
hr: 'editor-hr',
};
export default theme;