From 654f313ae9677809c1c5803e024b0909f4899aae Mon Sep 17 00:00:00 2001 From: cialloo Date: Wed, 22 Oct 2025 10:49:39 +0800 Subject: [PATCH] feat: Add code highlighting feature to the rich text editor --- package-lock.json | 8 +++ package.json | 2 + src/editor/RichTextEditor.tsx | 5 +- src/editor/plugins/CodeHighlightPlugin.tsx | 13 +++++ src/editor/plugins/ToolbarPlugin.tsx | 31 +++++++++++ src/editor/styles/editor.css | 63 +++++++++++++++++++++- src/editor/styles/toolbar.css | 4 ++ src/editor/themes/EditorTheme.ts | 34 +++++++++++- 8 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 src/editor/plugins/CodeHighlightPlugin.tsx diff --git a/package-lock.json b/package-lock.json index 0d2530c..1ed4753 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,11 @@ "@lexical/rich-text": "^0.37.0", "@lexical/selection": "^0.37.0", "@lexical/utils": "^0.37.0", + "@types/prismjs": "^1.26.5", "i18next": "^25.5.3", "i18next-browser-languagedetector": "^8.2.0", "lexical": "^0.37.0", + "prismjs": "^1.30.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^16.0.0", @@ -1342,6 +1344,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz", diff --git a/package.json b/package.json index 7ca73d1..4fab152 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "@lexical/rich-text": "^0.37.0", "@lexical/selection": "^0.37.0", "@lexical/utils": "^0.37.0", + "@types/prismjs": "^1.26.5", "i18next": "^25.5.3", "i18next-browser-languagedetector": "^8.2.0", "lexical": "^0.37.0", + "prismjs": "^1.30.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-i18next": "^16.0.0", diff --git a/src/editor/RichTextEditor.tsx b/src/editor/RichTextEditor.tsx index da7bcf5..089d268 100644 --- a/src/editor/RichTextEditor.tsx +++ b/src/editor/RichTextEditor.tsx @@ -4,11 +4,12 @@ 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 ToolbarPlugin from './plugins/ToolbarPlugin'; +import CodeHighlightPlugin from './plugins/CodeHighlightPlugin'; import editorTheme from './themes/EditorTheme'; import './styles/editor.css'; @@ -22,6 +23,7 @@ const editorConfig = { HeadingNode, QuoteNode, CodeNode, + CodeHighlightNode, ListNode, ListItemNode, ], @@ -44,6 +46,7 @@ export default function RichTextEditor() { /> + diff --git a/src/editor/plugins/CodeHighlightPlugin.tsx b/src/editor/plugins/CodeHighlightPlugin.tsx new file mode 100644 index 0000000..862d094 --- /dev/null +++ b/src/editor/plugins/CodeHighlightPlugin.tsx @@ -0,0 +1,13 @@ +import { registerCodeHighlighting } from '@lexical/code'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useEffect } from 'react'; + +export default function CodeHighlightPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} diff --git a/src/editor/plugins/ToolbarPlugin.tsx b/src/editor/plugins/ToolbarPlugin.tsx index 0fa420d..1ef5960 100644 --- a/src/editor/plugins/ToolbarPlugin.tsx +++ b/src/editor/plugins/ToolbarPlugin.tsx @@ -10,9 +10,12 @@ import { COMMAND_PRIORITY_CRITICAL, $getSelection, $isRangeSelection, + $createParagraphNode, } from 'lexical'; import type { TextFormatType } from 'lexical'; import { $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/selection'; +import { $setBlocksType } from '@lexical/selection'; +import { $createCodeNode, $isCodeNode } from '@lexical/code'; import DropdownColorPicker from '../ui/DropdownColorPicker'; import '../styles/toolbar.css'; @@ -27,6 +30,7 @@ export default function ToolbarPlugin() { const [isUnderline, setIsUnderline] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false); const [isCode, setIsCode] = useState(false); + const [isCodeBlock, setIsCodeBlock] = useState(false); const [fontColor, setFontColor] = useState('#000'); const [bgColor, setBgColor] = useState('#fff'); const [fontSize, setFontSize] = useState('15px'); @@ -41,6 +45,14 @@ export default function ToolbarPlugin() { setIsStrikethrough(selection.hasFormat('strikethrough')); setIsCode(selection.hasFormat('code')); + // Check if we're in a code block + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === 'root' + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + setIsCodeBlock($isCodeNode(element)); + // Update color setFontColor( $getSelectionStyleValueForProperty(selection, 'color', '#000') @@ -136,6 +148,19 @@ export default function ToolbarPlugin() { [editor] ); + const formatCodeBlock = useCallback(() => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + if (isCodeBlock) { + $setBlocksType(selection, () => $createParagraphNode()); + } else { + $setBlocksType(selection, () => $createCodeNode()); + } + } + }); + }, [editor, isCodeBlock]); + return (
+
'); } +i.format.code-block { + background-image: url('data:image/svg+xml;utf8,'); +} + i.icon.font-color { background-image: url('data:image/svg+xml;utf8,'); display: inline-block; diff --git a/src/editor/themes/EditorTheme.ts b/src/editor/themes/EditorTheme.ts index 7812103..30403e5 100644 --- a/src/editor/themes/EditorTheme.ts +++ b/src/editor/themes/EditorTheme.ts @@ -27,7 +27,39 @@ const theme: EditorThemeClasses = { code: 'editor-text-code', }, code: 'editor-code', - codeHighlight: {}, + codeHighlight: { + atrule: 'editor-token-attr', + attr: 'editor-token-attr', + boolean: 'editor-token-property', + builtin: 'editor-token-selector', + cdata: 'editor-token-comment', + char: 'editor-token-selector', + class: 'editor-token-function', + 'class-name': 'editor-token-function', + comment: 'editor-token-comment', + constant: 'editor-token-property', + deleted: 'editor-token-deleted', + doctype: 'editor-token-comment', + entity: 'editor-token-operator', + function: 'editor-token-function', + important: 'editor-token-variable', + inserted: 'editor-token-inserted', + keyword: 'editor-token-attr', + namespace: 'editor-token-variable', + number: 'editor-token-property', + operator: 'editor-token-operator', + prolog: 'editor-token-comment', + property: 'editor-token-property', + punctuation: 'editor-token-punctuation', + regex: 'editor-token-variable', + selector: 'editor-token-selector', + string: 'editor-token-selector', + symbol: 'editor-token-property', + tag: 'editor-token-property', + unchanged: 'editor-token-unchanged', + url: 'editor-token-operator', + variable: 'editor-token-variable', + }, }; export default theme;