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