From 414ab5232f3bf4a12d27b15faca00cffd946f52d Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 20 Oct 2025 20:48:43 +0800 Subject: [PATCH] feat: lexical editor more functional Added AutoLinkPlugin and TabIndentationPlugin to the LexicalRichEditor for improved user experience. Introduced ExitCodeBoundaryPlugin to manage caret behavior when exiting code contexts and TripleBacktickTogglePlugin for toggling code blocks. Updated node imports and types to support these new features, enhancing the editor's capabilities. Signed-off-by: Innei --- .../lexical-rich-editor/LexicalRichEditor.tsx | 39 +++++- .../src/ui/lexical-rich-editor/nodes.ts | 31 ++--- .../plugins/code-highlighting/index.tsx | 14 ++ .../plugins/exit-code/index.tsx | 121 ++++++++++++++++++ .../ui/lexical-rich-editor/plugins/index.ts | 3 + .../plugins/triple-backtick-toggle/index.tsx | 97 ++++++++++++++ .../src/ui/lexical-rich-editor/types.ts | 2 + 7 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 packages/internal/components/src/ui/lexical-rich-editor/plugins/code-highlighting/index.tsx create mode 100644 packages/internal/components/src/ui/lexical-rich-editor/plugins/exit-code/index.tsx create mode 100644 packages/internal/components/src/ui/lexical-rich-editor/plugins/triple-backtick-toggle/index.tsx diff --git a/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx b/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx index 28142e430..2fc557dd4 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx +++ b/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx @@ -1,6 +1,7 @@ -import { cn } from "@follow/utils" +import { cn, stopPropagation } from "@follow/utils" import { TRANSFORMERS } from "@lexical/markdown" import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin" +import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin" import type { InitialConfigType } from "@lexical/react/LexicalComposer" import { LexicalComposer } from "@lexical/react/LexicalComposer" import { ContentEditable } from "@lexical/react/LexicalContentEditable" @@ -12,12 +13,18 @@ import { ListPlugin } from "@lexical/react/LexicalListPlugin" import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin" import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin" import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin" +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin" import type { LexicalEditor } from "lexical" import { $getRoot } from "lexical" import { useImperativeHandle, useState } from "react" import { LexicalRichEditorNodes } from "./nodes" -import { KeyboardPlugin } from "./plugins" +import { + CodeHighlightingPlugin, + ExitCodeBoundaryPlugin, + KeyboardPlugin, + TripleBacktickTogglePlugin, +} from "./plugins" import { StringLengthChangePlugin } from "./plugins/string-length-change" import { defaultLexicalTheme } from "./theme" import type { BuiltInPlugins, LexicalRichEditorProps, LexicalRichEditorRef } from "./types" @@ -31,8 +38,29 @@ const defaultEnabledPlugins: BuiltInPlugins = { list: true, link: true, autoFocus: true, + autoLink: true, + tabIndentation: true, } +const URL_MATCHER = + /((https?:\/\/(www\.)?)|(www\.))[-\w@:%.+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-\w()@:%+.~#?&/=]*)/ + +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}`, + attributes: { rel: "noreferrer", target: "_blank" }, + } + }, +] export const LexicalRichEditor = function LexicalRichEditor({ ref, placeholder = "Enter your message...", @@ -84,6 +112,7 @@ export const LexicalRichEditor = function LexicalRichEditor({ } {onLengthChange && } - + {enabledPlugins.tabIndentation && } + {enabledPlugins.autoLink && } {enabledPlugins.history && } {enabledPlugins.markdown && } {enabledPlugins.list && } @@ -113,6 +143,9 @@ export const LexicalRichEditor = function LexicalRichEditor({ ))} + + + {autoFocus && enabledPlugins.autoFocus && } diff --git a/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts b/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts index 7bf89baa3..83ae8eaff 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts @@ -1,33 +1,20 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code" -import { LinkNode } from "@lexical/link" +import { AutoLinkNode, LinkNode } from "@lexical/link" import { ListItemNode, ListNode } from "@lexical/list" import { MarkNode } from "@lexical/mark" import { HeadingNode, QuoteNode } from "@lexical/rich-text" import { ParagraphNode, TextNode } from "lexical" -// Mention functionality moved to desktop app, import should be handled there -// import { MentionNode } from "./plugins/mention" - export const LexicalRichEditorNodes = [ - // Core nodes ParagraphNode, TextNode, - - // Rich text nodes - HeadingNode, // For HEADING transformer - QuoteNode, // For QUOTE transformer - - // List nodes - ListNode, // For UNORDERED_LIST, ORDERED_LIST transformers + AutoLinkNode, + HeadingNode, + QuoteNode, + ListNode, ListItemNode, - - // Code nodes - CodeNode, // For CODE transformer (multiline) - CodeHighlightNode, // For code syntax highlighting - - // Link nodes - LinkNode, // For LINK transformer - - // Text format nodes - MarkNode, // For HIGHLIGHT transformer + CodeNode, + LinkNode, + MarkNode, + CodeHighlightNode, ] diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/code-highlighting/index.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/code-highlighting/index.tsx new file mode 100644 index 000000000..683c03e4b --- /dev/null +++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/code-highlighting/index.tsx @@ -0,0 +1,14 @@ +import { registerCodeHighlighting } from "@lexical/code" +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" +import { useEffect } from "react" + +export function CodeHighlightingPlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + const unregister = registerCodeHighlighting(editor) + return () => unregister() + }, [editor]) + + return null +} diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/exit-code/index.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/exit-code/index.tsx new file mode 100644 index 000000000..852169f5a --- /dev/null +++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/exit-code/index.tsx @@ -0,0 +1,121 @@ +import { $isCodeNode } from "@lexical/code" +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" +import { + $createParagraphNode, + $createTextNode, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_NORMAL, + KEY_ARROW_RIGHT_COMMAND, + KEY_ENTER_COMMAND, +} from "lexical" +import { useEffect } from "react" + +/** + * Handles exiting from inline and block code contexts: + * - Inline code: Enter or ArrowRight at the end inserts a space after the code span and moves caret there. + * - Block code: ArrowRight at the end inserts an empty paragraph after the code block and moves caret there. + */ +export function ExitCodeBoundaryPlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + // Enter inside inline code at end → insert a trailing space outside code + const unregisterEnter = editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + let handled = false + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return + + const focusNode = selection.focus.getNode() + if ($isTextNode(focusNode) && focusNode.hasFormat("code")) { + const atEnd = selection.focus.offset === focusNode.getTextContentSize() + if (atEnd) { + const space = $createTextNode(" ") // outside code format + // Insert after current code-formatted text node + focusNode.insertAfter(space) + // Place caret after inserted space + space.select(1, 1) + handled = true + } + } + }) + if (handled) { + if (event) { + event.preventDefault() + event.stopPropagation() + } + return true + } + return false + }, + COMMAND_PRIORITY_NORMAL, + ) + + // ArrowRight inside inline code at end → same as Enter behavior + const unregisterArrowRight = editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + (event) => { + let handled = false + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return + + const focusNode = selection.focus.getNode() + + // 1) Inline code span end → insert space outside code + if ($isTextNode(focusNode) && focusNode.hasFormat("code")) { + const atEnd = selection.focus.offset === focusNode.getTextContentSize() + if (atEnd) { + const space = $createTextNode(" ") + focusNode.insertAfter(space) + space.select(1, 1) + handled = true + return + } + } + + // 2) Block code (CodeNode) end → insert empty paragraph after code block + const codeAncestor = focusNode.getParent() + if (codeAncestor && $isCodeNode(codeAncestor)) { + // Heuristic: only exit when the caret is at the very end of the last descendant + const lastDescendant = codeAncestor.getLastDescendant() + if ($isTextNode(lastDescendant)) { + const isAtEnd = + lastDescendant.getKey() === selection.focus.getNode().getKey() && + selection.focus.offset === lastDescendant.getTextContentSize() + if (isAtEnd) { + const paragraph = $createParagraphNode() + const text = $createTextNode("") + paragraph.append(text) + // Insert after the code block and move caret into the new empty line + codeAncestor.insertAfter(paragraph) + text.select(0, 0) + handled = true + } + } + } + }) + if (handled) { + if (event) { + event.preventDefault() + event.stopPropagation() + } + return true + } + return false + }, + COMMAND_PRIORITY_NORMAL, + ) + + return () => { + unregisterEnter() + unregisterArrowRight() + } + }, [editor]) + + return null +} diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts b/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts index 939cf4b14..6bb89dd24 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts @@ -1,2 +1,5 @@ +export { CodeHighlightingPlugin } from "./code-highlighting" +export { ExitCodeBoundaryPlugin } from "./exit-code" export { KeyboardPlugin } from "./keyboard" export { StringLengthChangePlugin } from "./string-length-change" +export { TripleBacktickTogglePlugin } from "./triple-backtick-toggle" diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/triple-backtick-toggle/index.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/triple-backtick-toggle/index.tsx new file mode 100644 index 000000000..149bd44f1 --- /dev/null +++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/triple-backtick-toggle/index.tsx @@ -0,0 +1,97 @@ +import { $createCodeNode, $isCodeNode } from "@lexical/code" +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" +import type { LexicalNode } from "lexical" +import { + $createParagraphNode, + $createTextNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_HIGH, + KEY_ENTER_COMMAND, +} from "lexical" +import { useEffect } from "react" + +function findAncestor( + node: LexicalNode | null, + predicate: (n: LexicalNode) => boolean, +): LexicalNode | null { + let current: LexicalNode | null = node + while (current) { + if (predicate(current)) return current + current = current.getParent() + } + return null +} + +export function TripleBacktickTogglePlugin() { + const [editor] = useLexicalComposerContext() + + useEffect(() => { + const unregister = editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event) return false + + // Phase 1 (read): decide intent without mutating editor + const intent = editor.getEditorState().read(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null + const focusNode = selection.focus.getNode() + const topLevel = focusNode.getTopLevelElementOrThrow() + + // Toggle ON if paragraph is exactly ``` or ```lang + if (topLevel.getType() === "paragraph") { + const raw = topLevel.getTextContent() + const text = raw.trim() + const match = /^```([\w+-]+)?$/.exec(text) + if (match) { + return { type: "toggleOn" as const, lang: match[1] as string | undefined } + } + } + + // Shift+Enter inside a code block should exit the block + const codeAncestor = findAncestor(focusNode, (n) => $isCodeNode(n)) + if (event.shiftKey && codeAncestor && $isCodeNode(codeAncestor)) { + return { type: "exitCode" as const } + } + return null + }) + + if (!intent) return false + + // Phase 2 (write): perform mutation according to intent + editor.update(() => { + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return + const focusNode = selection.focus.getNode() + + if (intent.type === "toggleOn") { + const topLevel = focusNode.getTopLevelElementOrThrow() + const codeNode = $createCodeNode(intent.lang) + topLevel.replace(codeNode) + codeNode.selectEnd() + } else if (intent.type === "exitCode") { + const codeAncestor = findAncestor(focusNode, (n) => $isCodeNode(n)) + if (codeAncestor && $isCodeNode(codeAncestor)) { + const paragraph = $createParagraphNode() + const text = $createTextNode("") + paragraph.append(text) + codeAncestor.insertAfter(paragraph) + text.select() + } + } + }) + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + return true + }, + COMMAND_PRIORITY_HIGH, + ) + + return () => unregister() + }, [editor]) + + return null +} diff --git a/packages/internal/components/src/ui/lexical-rich-editor/types.ts b/packages/internal/components/src/ui/lexical-rich-editor/types.ts index 31a320473..fbf1f9b91 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/types.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/types.ts @@ -14,6 +14,8 @@ export interface BuiltInPlugins { list?: boolean link?: boolean autoFocus?: boolean + autoLink?: boolean + tabIndentation?: boolean } export interface LexicalRichEditorProps { placeholder?: string