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
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:
1517
package-lock.json
generated
1517
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/code": "^0.37.0",
|
||||
"@lexical/link": "^0.37.0",
|
||||
"@lexical/list": "^0.37.0",
|
||||
"@lexical/markdown": "^0.37.0",
|
||||
"@lexical/react": "^0.37.0",
|
||||
"@lexical/rich-text": "^0.37.0",
|
||||
"@lexical/selection": "^0.37.0",
|
||||
"@lexical/table": "^0.37.0",
|
||||
"@lexical/utils": "^0.37.0",
|
||||
"i18next": "^25.5.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -35,9 +38,6 @@
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "npm:rolldown-vite@7.1.12"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.1.12"
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
src/editor/plugins/MarkdownShortcutPlugin.tsx
Normal file
7
src/editor/plugins/MarkdownShortcutPlugin.tsx
Normal 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} />;
|
||||
}
|
||||
229
src/editor/plugins/MarkdownTransformers.ts
Normal file
229
src/editor/plugins/MarkdownTransformers.ts
Normal 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,
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user