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