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:
parent
832f33485c
commit
414ab5232f
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ export interface BuiltInPlugins {
|
|||
list?: boolean
|
||||
link?: boolean
|
||||
autoFocus?: boolean
|
||||
autoLink?: boolean
|
||||
tabIndentation?: boolean
|
||||
}
|
||||
export interface LexicalRichEditorProps {
|
||||
placeholder?: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue