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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-10-20 20:48:43 +08:00
parent 832f33485c
commit 414ab5232f
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
7 changed files with 282 additions and 25 deletions

View File

@ -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({
<RichTextPlugin
contentEditable={
<ContentEditable
onContextMenu={stopPropagation}
className={cn(
"scrollbar-none text-text placeholder:text-text-secondary size-full cursor-text",
"size-full resize-none bg-transparent",
@ -103,7 +132,8 @@ export const LexicalRichEditor = function LexicalRichEditor({
{onChange && <OnChangePlugin onChange={onChange} />}
{onLengthChange && <StringLengthChangePlugin onChange={onLengthChange} />}
<EditorRefPlugin editorRef={setEditorRef} />
{enabledPlugins.tabIndentation && <TabIndentationPlugin />}
{enabledPlugins.autoLink && <AutoLinkPlugin matchers={MATCHERS} />}
{enabledPlugins.history && <HistoryPlugin />}
{enabledPlugins.markdown && <MarkdownShortcutPlugin transformers={TRANSFORMERS} />}
{enabledPlugins.list && <ListPlugin />}
@ -113,6 +143,9 @@ export const LexicalRichEditor = function LexicalRichEditor({
<Plugin key={Plugin.id} />
))}
<ExitCodeBoundaryPlugin />
<CodeHighlightingPlugin />
<TripleBacktickTogglePlugin />
<KeyboardPlugin onKeyDown={onKeyDown} />
{autoFocus && enabledPlugins.autoFocus && <AutoFocusPlugin />}
</div>

View File

@ -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,
]

View File

@ -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
}

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -14,6 +14,8 @@ export interface BuiltInPlugins {
list?: boolean
link?: boolean
autoFocus?: boolean
autoLink?: boolean
tabIndentation?: boolean
}
export interface LexicalRichEditorProps {
placeholder?: string