diff --git a/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx b/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx index 455be2d216..df8ba55a8b 100644 --- a/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx +++ b/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx @@ -22,6 +22,7 @@ jest.mock('../../../../../components/drops/create/lexical/plugins/DragDropPasteP jest.mock('../../../../../components/drops/create/lexical/plugins/enter/EnterKeyPlugin', () => () => null); jest.mock('../../../../../components/drops/create/lexical/plugins/AutoFocusPlugin', () => () => null); jest.mock('../../../../../components/drops/create/lexical/plugins/emoji/EmojiPlugin', () => () => null); +jest.mock('../../../../../components/drops/create/lexical/plugins/PlainTextPastePlugin', () => () => null); jest.mock('../../../../../components/waves/CreateDropEmojiPicker', () => () =>
); jest.mock('../../../../../components/drops/create/utils/storm/CreateDropParts', () => () =>
); jest.mock('../../../../../components/drops/create/utils/CreateDropActionsRow', () => () =>
); diff --git a/__tests__/components/waves/drops/EditDropLexical.test.tsx b/__tests__/components/waves/drops/EditDropLexical.test.tsx index 82c2d1554d..065f3ac98f 100644 --- a/__tests__/components/waves/drops/EditDropLexical.test.tsx +++ b/__tests__/components/waves/drops/EditDropLexical.test.tsx @@ -1,56 +1,56 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import EditDropLexical from '../../../../components/waves/drops/EditDropLexical'; -import { ApiDropMentionedUser } from '../../../../generated/models/ApiDropMentionedUser'; +import type { ApiDropMentionedUser } from '../../../../generated/models/ApiDropMentionedUser'; + +type MentionSelectHandler = (user: { + mentioned_profile_id: string; + handle_in_content: string; +}) => void; + +const rootMock = { + getChildren: jest.fn(() => [] as unknown[]), + getAllTextNodes: jest.fn(() => [] as unknown[]), + selectEnd: jest.fn(), +}; +const getRootMock = jest.fn(() => rootMock); +const editorMock = { + update: jest.fn(), + getEditorState: jest.fn(() => ({ + read: (fn: () => void) => fn(), + })), + registerCommand: jest.fn(() => jest.fn()), + focus: jest.fn(), +}; + +let mentionSelectHandler: MentionSelectHandler | null = null; -// Mock all Lexical dependencies jest.mock('@lexical/list', () => ({ - ListNode: class MockListNode { - static getType() { return 'list'; } - }, - ListItemNode: class MockListItemNode { - static getType() { return 'listitem'; } - }, + ListNode: class MockListNode {}, + ListItemNode: class MockListItemNode {}, })); - jest.mock('@lexical/rich-text', () => ({ - HeadingNode: class MockHeadingNode { - static getType() { return 'heading'; } - }, - QuoteNode: class MockQuoteNode { - static getType() { return 'quote'; } - }, + HeadingNode: class MockHeadingNode {}, + QuoteNode: class MockQuoteNode {}, })); - jest.mock('@lexical/react/LexicalHorizontalRuleNode', () => ({ - HorizontalRuleNode: class MockHorizontalRuleNode { - static getType() { return 'horizontalrule'; } - }, + HorizontalRuleNode: class MockHorizontalRuleNode {}, })); - jest.mock('@lexical/code', () => ({ - CodeHighlightNode: class MockCodeHighlightNode { - static getType() { return 'code-highlight'; } - }, - CodeNode: class MockCodeNode { - static getType() { return 'code'; } - }, + CodeHighlightNode: class MockCodeHighlightNode {}, + CodeNode: class MockCodeNode {}, + $isCodeNode: () => false, })); - jest.mock('@lexical/link', () => ({ - AutoLinkNode: class MockAutoLinkNode { - static getType() { return 'autolink'; } - }, - LinkNode: class MockLinkNode { - static getType() { return 'link'; } - }, + AutoLinkNode: class MockAutoLinkNode {}, + LinkNode: class MockLinkNode {}, })); - jest.mock('@lexical/react/LexicalComposer', () => ({ - LexicalComposer: ({ children }: any) =>
{children}
, + LexicalComposer: ({ children }: any) => ( +
{children}
+ ), })); - jest.mock('@lexical/react/LexicalRichTextPlugin', () => ({ RichTextPlugin: ({ contentEditable, placeholder }: any) => (
@@ -59,126 +59,66 @@ jest.mock('@lexical/react/LexicalRichTextPlugin', () => ({
), })); - jest.mock('@lexical/react/LexicalContentEditable', () => ({ ContentEditable: ({ className, style }: any) => ( -
), })); - jest.mock('@lexical/react/LexicalErrorBoundary', () => ({ __esModule: true, - default: ({ children }: any) =>
{children}
, + default: () => null, })); - jest.mock('@lexical/react/LexicalHistoryPlugin', () => ({ - HistoryPlugin: () =>
, + HistoryPlugin: () => null, })); - jest.mock('@lexical/react/LexicalOnChangePlugin', () => ({ OnChangePlugin: ({ onChange }: any) => { - // Simulate editor state change React.useEffect(() => { const mockEditorState = { read: (fn: () => void) => fn(), }; onChange(mockEditorState); }, [onChange]); - return
; + return null; }, })); - jest.mock('@lexical/react/LexicalMarkdownShortcutPlugin', () => ({ - MarkdownShortcutPlugin: () =>
, + MarkdownShortcutPlugin: () => null, })); - jest.mock('@lexical/react/LexicalListPlugin', () => ({ - ListPlugin: () =>
, + ListPlugin: () => null, })); - jest.mock('@lexical/react/LexicalLinkPlugin', () => ({ - LinkPlugin: () =>
, -})); - -jest.mock('@lexical/react/LexicalComposerContext', () => ({ - useLexicalComposerContext: () => [ - { - update: jest.fn((fn) => fn()), - getEditorState: () => ({ - read: (fn: () => void) => fn(), - }), - registerCommand: jest.fn(() => jest.fn()), - focus: jest.fn(), - }, - ], -})); - -jest.mock('@lexical/markdown', () => ({ - TRANSFORMERS: [], - $convertFromMarkdownString: jest.fn(), - $convertToMarkdownString: jest.fn(() => 'mock markdown'), -})); - -jest.mock('lexical', () => ({ - $getRoot: jest.fn(() => ({ - getAllTextNodes: jest.fn(() => []), - selectEnd: jest.fn(), - })), - COMMAND_PRIORITY_HIGH: 4, - KEY_ENTER_COMMAND: 'KEY_ENTER_COMMAND', - KEY_ESCAPE_COMMAND: 'KEY_ESCAPE_COMMAND', - TextNode: class MockTextNode {}, -})); - -// Mock the custom plugins and nodes -jest.mock('../../../../components/drops/create/lexical/nodes/MentionNode', () => ({ - MentionNode: class MockMentionNode { - static getType() { return 'mention'; } - }, - $createMentionNode: jest.fn(() => ({ type: 'mention' })), -})); - -jest.mock('../../../../components/drops/create/lexical/nodes/HashtagNode', () => ({ - HashtagNode: class MockHashtagNode { - static getType() { return 'hashtag'; } - }, -})); - -jest.mock('../../../../components/drops/create/lexical/transformers/MentionTransformer', () => ({ - MENTION_TRANSFORMER: { type: 'mention-transformer' }, -})); - -jest.mock('../../../../components/drops/create/lexical/transformers/HastagTransformer', () => ({ - HASHTAG_TRANSFORMER: { type: 'hashtag-transformer' }, + LinkPlugin: () => null, })); - -jest.mock('../../../../components/drops/create/lexical/nodes/EmojiNode', () => ({ - EmojiNode: class MockEmojiNode { - static getType() { return 'emoji'; } - }, +jest.mock('../../../../components/drops/create/lexical/plugins/PlainTextPastePlugin', () => ({ + __esModule: true, + default: () => null, })); - jest.mock('../../../../components/drops/create/lexical/plugins/emoji/EmojiPlugin', () => ({ __esModule: true, - default: () =>
, + default: () => null, })); - -jest.mock('../../../../components/drops/create/lexical/ExampleTheme', () => ({ +jest.mock('../../../../components/waves/CreateDropEmojiPicker', () => ({ __esModule: true, - default: {}, + default: () =>
, +})); +jest.mock('../../../../hooks/useDeviceInfo', () => ({ + __esModule: true, + default: () => ({ isApp: false }), })); - jest.mock('../../../../components/drops/create/lexical/plugins/mentions/MentionsPlugin', () => { const React = require('react'); return { __esModule: true, default: React.forwardRef(({ onSelect }: any, ref: any) => { + mentionSelectHandler = onSelect; React.useImperativeHandle(ref, () => ({ isMentionsOpen: jest.fn(() => false), })); @@ -186,15 +126,63 @@ jest.mock('../../../../components/drops/create/lexical/plugins/mentions/Mentions }), }; }); - -jest.mock('../../../../components/waves/CreateDropEmojiPicker', () => ({ +jest.mock('../../../../components/drops/create/lexical/nodes/MentionNode', () => ({ + MentionNode: class MockMentionNode {}, + $createMentionNode: jest.fn(() => ({ type: 'mention' })), +})); +jest.mock('../../../../components/drops/create/lexical/nodes/HashtagNode', () => ({ + HashtagNode: class MockHashtagNode {}, +})); +jest.mock('../../../../components/drops/create/lexical/nodes/EmojiNode', () => ({ + EmojiNode: class MockEmojiNode {}, +})); +jest.mock('../../../../components/drops/create/lexical/ExampleTheme', () => ({ __esModule: true, - default: () =>
, + default: {}, })); - -jest.mock('../../../../hooks/useDeviceInfo', () => ({ +jest.mock('../../../../components/drops/create/lexical/transformers/MentionTransformer', () => ({ + MENTION_TRANSFORMER: {}, +})); +jest.mock('../../../../components/drops/create/lexical/transformers/HastagTransformer', () => ({ + HASHTAG_TRANSFORMER: {}, +})); +jest.mock('@/components/drops/create/lexical/transformers/markdownTransformers', () => ({ + SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE: [], +})); +jest.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: () => [editorMock], +})); +jest.mock('@lexical/markdown', () => ({ __esModule: true, - default: () => ({ isApp: false }), + $convertFromMarkdownString: jest.fn(), + $convertToMarkdownString: jest.fn(() => 'mock markdown'), +})); +const { + $convertFromMarkdownString: convertFromMarkdownStringMock, + $convertToMarkdownString: convertToMarkdownStringMock, +} = jest.requireMock('@lexical/markdown') as { + $convertFromMarkdownString: jest.Mock; + $convertToMarkdownString: jest.Mock; +}; +jest.mock('lexical', () => ({ + $getRoot: getRootMock, + COMMAND_PRIORITY_HIGH: 4, + KEY_ENTER_COMMAND: 'KEY_ENTER_COMMAND', + KEY_ESCAPE_COMMAND: 'KEY_ESCAPE_COMMAND', + TextNode: class MockTextNode {}, + $createParagraphNode: jest.fn(() => ({ + append: jest.fn(), + replace: jest.fn(), + })), + $createTextNode: jest.fn(() => ({ + setTextContent: jest.fn(), + insertAfter: jest.fn(), + insertBefore: jest.fn(), + remove: jest.fn(), + getTextContent: jest.fn(() => ''), + })), + $isElementNode: jest.fn(() => false), + $isCodeNode: jest.fn(() => false), })); describe('EditDropLexical', () => { @@ -209,175 +197,92 @@ describe('EditDropLexical', () => { beforeEach(() => { jest.clearAllMocks(); + convertToMarkdownStringMock.mockReturnValue('mock markdown'); + convertFromMarkdownStringMock.mockReset(); + rootMock.getChildren.mockReturnValue([]); + rootMock.getAllTextNodes.mockReturnValue([]); + mentionSelectHandler = null; }); - describe('Component Rendering', () => { - it('renders without crashing', () => { - render(); - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - }); + it('renders placeholder text and emoji picker', () => { + render(); + expect(screen.getByText('Edit message...')).toBeInTheDocument(); + expect(screen.getByTestId('emoji-picker')).toBeInTheDocument(); + }); - it('renders all required plugins', () => { - render(); - - expect(screen.getByTestId('rich-text-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('content-editable')).toBeInTheDocument(); - expect(screen.getByTestId('onchange-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('history-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('markdown-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('list-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('link-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('mentions-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('emoji-plugin')).toBeInTheDocument(); - expect(screen.getByTestId('emoji-picker')).toBeInTheDocument(); - }); + it('saves updated markdown together with unique mentions', async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + const onCancel = jest.fn(); + convertToMarkdownStringMock.mockReturnValue('updated markdown'); - it('renders save and cancel buttons', () => { - render(); - - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); - }); + render(); - it('displays help text for keyboard shortcuts', () => { - render(); - - expect(screen.getByText(/escape to/i)).toBeInTheDocument(); - expect(screen.getByText(/enter to/i)).toBeInTheDocument(); - }); + expect(mentionSelectHandler).toBeTruthy(); + const mention = { mentioned_profile_id: 'profile-1', handle_in_content: 'user1' }; - it('renders placeholder text', () => { - render(); - - expect(screen.getByText('Edit message...')).toBeInTheDocument(); + await act(async () => { + mentionSelectHandler?.(mention); + mentionSelectHandler?.(mention); }); - }); - describe('Button Interactions', () => { - it('calls onCancel when cancel button is clicked', async () => { - const user = userEvent.setup(); - const onCancel = jest.fn(); - - render(); - - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - await user.click(cancelButton); - - expect(onCancel).toHaveBeenCalledTimes(1); - }); + const saveButton = screen.getByRole('button', { name: /save/i }); + await user.click(saveButton); - it('calls handleSave when save button is clicked', async () => { - const user = userEvent.setup(); - const onSave = jest.fn(); - - render(); - - const saveButton = screen.getByRole('button', { name: /save/i }); - await user.click(saveButton); - - // Note: handleSave will call onCancel if no changes detected (mock markdown returns 'mock markdown' vs 'Initial content here') - // In a real test environment, we'd mock the markdown conversion to simulate content changes - expect(onSave).toHaveBeenCalledWith('mock markdown', []); - }); + expect(onSave).toHaveBeenCalledWith('updated markdown', [ + { mentioned_profile_id: 'profile-1', handle_in_content: 'user1' }, + ]); + expect(onCancel).not.toHaveBeenCalled(); }); - describe('Content Handling', () => { - it('initializes with provided content', () => { - const initialContent = 'Test initial content'; - render(); - - // The initial content is processed through the InitialContentPlugin - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - }); + it('calls onCancel when markdown has not changed', async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + const onCancel = jest.fn(); + convertToMarkdownStringMock.mockReturnValue('Initial content here'); - it('initializes with provided mentions', () => { - const initialMentions: ApiDropMentionedUser[] = [ - { - mentioned_profile_id: 'profile-1', - handle_in_content: 'user1', - }, - ]; - - render(); - - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - }); - }); + render(); - describe('Keyboard Shortcuts', () => { - it('handles escape key to cancel', () => { - const onCancel = jest.fn(); - render(); - - // Simulate escape key press - const contentEditable = screen.getByTestId('content-editable'); - fireEvent.keyDown(contentEditable, { key: 'Escape', code: 'Escape' }); - - // Note: In the actual implementation, this is handled by Lexical's command system - // This test verifies the component structure is in place - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - }); + const saveButton = screen.getByRole('button', { name: /save/i }); + await user.click(saveButton); - it('handles enter key to save', () => { - const onSave = jest.fn(); - render(); - - // Simulate enter key press - const contentEditable = screen.getByTestId('content-editable'); - fireEvent.keyDown(contentEditable, { key: 'Enter', code: 'Enter' }); - - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - }); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); }); - describe('Saving State', () => { - it('disables interactions when saving', () => { - render(); - - // Component should still render but be in saving state - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - }); + it('invokes keyboard command handlers', async () => { + const onSave = jest.fn(); + const onCancel = jest.fn(); + convertToMarkdownStringMock.mockReturnValue('changed content'); - describe('Wave Context', () => { - it('handles null waveId', () => { - render(); - - expect(screen.getByTestId('mentions-plugin')).toBeInTheDocument(); - }); + render(); - it('passes waveId to mentions plugin', () => { - const waveId = 'test-wave-id'; - render(); - - expect(screen.getByTestId('mentions-plugin')).toBeInTheDocument(); - }); - }); + await act(async () => {}); + + const escapeCall = editorMock.registerCommand.mock.calls.find( + ([command]) => command === 'KEY_ESCAPE_COMMAND' + ); + const enterCall = editorMock.registerCommand.mock.calls.find( + ([command]) => command === 'KEY_ENTER_COMMAND' + ); - describe('Error Handling', () => { - it('handles editor errors gracefully', () => { - // Mock console.error to avoid noise in test output - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - render(); - - // The component should render successfully without throwing - expect(screen.getByTestId('lexical-composer')).toBeInTheDocument(); - - consoleSpy.mockRestore(); + expect(escapeCall).toBeDefined(); + expect(enterCall).toBeDefined(); + + const escapeHandler = escapeCall?.[1] as () => boolean; + const enterHandler = enterCall?.[1] as (event?: { shiftKey?: boolean }) => boolean; + + await act(async () => { + escapeHandler?.(); }); - }); + expect(onCancel).toHaveBeenCalledTimes(1); - describe('Focus Management', () => { - it('attempts to focus the editor on mount', async () => { - render(); - - // The component should render and attempt focus - await waitFor(() => { - expect(screen.getByTestId('content-editable')).toBeInTheDocument(); - }); + let handled = false; + await act(async () => { + handled = enterHandler?.({ shiftKey: false }) ?? false; }); + expect(handled).toBe(true); + expect(convertToMarkdownStringMock).toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); }); }); diff --git a/components/drops/create/lexical/lexical.styles.scss b/components/drops/create/lexical/lexical.styles.scss index 4305b84ff9..202ad9bd95 100644 --- a/components/drops/create/lexical/lexical.styles.scss +++ b/components/drops/create/lexical/lexical.styles.scss @@ -40,6 +40,16 @@ height: 200px; } +.editor-code, +.editor-text-code { + color: #e5e7eb; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.editor-code { + background-color: transparent; +} + .editor-nested-listitem { list-style-type: none; } diff --git a/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx new file mode 100644 index 0000000000..0478b8843a --- /dev/null +++ b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + COMMAND_PRIORITY_LOW, + PASTE_COMMAND, + $getSelection, + $isRangeSelection, +} from "lexical"; +import { useEffect } from "react"; + +const TEXT_MIME_TYPE = "text/plain"; + +export default function PlainTextPastePlugin(): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + PASTE_COMMAND, + (event) => { + const clipboardData = event.clipboardData; + if (!clipboardData) { + return false; + } + + if (clipboardData.files.length > 0) { + return false; + } + + const text = clipboardData.getData(TEXT_MIME_TYPE); + if (!text.length) { + return false; + } + + event.preventDefault(); + + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertRawText(text); + } + }); + + return true; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor]); + + return null; +} diff --git a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx index 767f8ac515..65f5af36f6 100644 --- a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx +++ b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx @@ -24,6 +24,7 @@ import HashtagsTypeaheadMenu from "./HashtagsTypeaheadMenu"; import { isEthereumAddress } from "../../../../../../helpers/AllowlistToolHelpers"; import { ReferencedNft } from "../../../../../../entities/IDrop"; import { ReservoirTokensResponseTokenElement } from "../../../../../../entities/IReservoir"; +import { isInCodeContext } from "../../utils/codeContextDetection"; const PUNCTUATION = "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;"; @@ -238,6 +239,10 @@ const NewHashtagsPlugin = forwardRef< const checkForHashtagMatch = useCallback( (text: string) => { + if (isInCodeContext(editor)) { + return null; + } + const slashMatch = checkForSlashTriggerMatch(text, editor); if (slashMatch !== null) { return null; diff --git a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx index bc329da04a..3184b85c42 100644 --- a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx +++ b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx @@ -23,6 +23,7 @@ import { $createMentionNode } from "../../nodes/MentionNode"; import MentionsTypeaheadMenu from "./MentionsTypeaheadMenu"; import { MentionedUser } from "../../../../../../entities/IDrop"; import { useIdentitiesSearch } from "../../../../../../hooks/useIdentitiesSearch"; +import { isInCodeContext } from "../../utils/codeContextDetection"; const PUNCTUATION = "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;"; @@ -210,6 +211,10 @@ const NewMentionsPlugin = forwardRef< const checkForMentionMatch = useCallback( (text: string) => { + if (isInCodeContext(editor)) { + return null; + } + const slashMatch = checkForSlashTriggerMatch(text, editor); if (slashMatch !== null) { return null; diff --git a/components/drops/create/lexical/transformers/markdownTransformers.ts b/components/drops/create/lexical/transformers/markdownTransformers.ts new file mode 100644 index 0000000000..ff80fddd53 --- /dev/null +++ b/components/drops/create/lexical/transformers/markdownTransformers.ts @@ -0,0 +1,48 @@ +import { TRANSFORMERS, type Transformer } from "@lexical/markdown"; + +const UNDERSCORE_TAGS = new Set(["__", "___", "_"]); + +const isObjectTransformer = ( + transformer: unknown +): transformer is Transformer => + typeof transformer === "object" && transformer !== null; + +const BASE_SAFE_TRANSFORMERS = TRANSFORMERS.filter((transformer) => { + if (!isObjectTransformer(transformer)) { + return true; + } + + const maybeTag = (transformer as { tag?: unknown }).tag; + if (typeof maybeTag !== "string") { + return true; + } + + return !UNDERSCORE_TAGS.has(maybeTag); +}); + +const isCodeTransformer = (transformer: Transformer): boolean => { + const dependencies = (transformer as { dependencies?: unknown }).dependencies; + if (!Array.isArray(dependencies)) { + return false; + } + + return dependencies.some((dependency) => { + if ( + !dependency || + typeof dependency !== "object" || + typeof (dependency as { getType?: unknown }).getType !== "function" + ) { + return false; + } + + return (dependency as { getType: () => string }).getType() === "code"; + }); +}; + +export const SAFE_MARKDOWN_TRANSFORMERS = BASE_SAFE_TRANSFORMERS; + +export const SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE = + BASE_SAFE_TRANSFORMERS.filter( + (transformer) => + !isObjectTransformer(transformer) || !isCodeTransformer(transformer) + ); diff --git a/components/drops/create/lexical/utils/codeContextDetection.ts b/components/drops/create/lexical/utils/codeContextDetection.ts new file mode 100644 index 0000000000..e07032f7c8 --- /dev/null +++ b/components/drops/create/lexical/utils/codeContextDetection.ts @@ -0,0 +1,34 @@ +import { $getSelection, $isRangeSelection } from "lexical"; +import type { LexicalEditor, LexicalNode } from "lexical"; +import { $isCodeNode } from "@lexical/code"; + +export function isInCodeContext(editor: LexicalEditor): boolean { + return editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + if (selection.hasFormat("code")) { + return true; + } + + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + + const isNodeWithinCode = (node: LexicalNode | null) => { + if (!node) { + return false; + } + + if ($isCodeNode(node)) { + return true; + } + + const topLevel = node.getTopLevelElement(); + return $isCodeNode(topLevel); + }; + + return isNodeWithinCode(anchorNode) || isNodeWithinCode(focusNode); + }); +} diff --git a/components/drops/create/utils/CreateDropContent.tsx b/components/drops/create/utils/CreateDropContent.tsx index 772ad38dd9..8eaaebdfc0 100644 --- a/components/drops/create/utils/CreateDropContent.tsx +++ b/components/drops/create/utils/CreateDropContent.tsx @@ -27,7 +27,7 @@ import { import { MaxLengthPlugin } from "../lexical/plugins/MaxLengthPlugin"; import ToggleViewButtonPlugin from "../lexical/plugins/ToggleViewButtonPlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; -import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"; +import { $convertToMarkdownString } from "@lexical/markdown"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { ListNode, ListItemNode } from "@lexical/list"; @@ -63,11 +63,13 @@ import { ImageNode } from "../lexical/nodes/ImageNode"; import CreateDropParts from "./storm/CreateDropParts"; import CreateDropActionsRow from "./CreateDropActionsRow"; import { IMAGE_TRANSFORMER } from "../lexical/transformers/ImageTransformer"; +import { SAFE_MARKDOWN_TRANSFORMERS } from "../lexical/transformers/markdownTransformers"; import EnterKeyPlugin from "../lexical/plugins/enter/EnterKeyPlugin"; import AutoFocusPlugin from "../lexical/plugins/AutoFocusPlugin"; import { EmojiNode } from "../lexical/nodes/EmojiNode"; import CreateDropEmojiPicker from "../../../waves/CreateDropEmojiPicker"; import EmojiPlugin from "../lexical/plugins/emoji/EmojiPlugin"; +import PlainTextPastePlugin from "../lexical/plugins/PlainTextPastePlugin"; export interface CreateDropContentHandles { clearEditorState: () => void; @@ -194,7 +196,7 @@ const CreateDropContent = forwardRef< editorState?.read(() => setCharsCount( $convertToMarkdownString([ - ...TRANSFORMERS, + ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, IMAGE_TRANSFORMER, @@ -283,7 +285,8 @@ const CreateDropContent = forwardRef< - + + diff --git a/components/drops/create/utils/CreateDropWrapper.tsx b/components/drops/create/utils/CreateDropWrapper.tsx index ee59cdcbdf..197dac3d2c 100644 --- a/components/drops/create/utils/CreateDropWrapper.tsx +++ b/components/drops/create/utils/CreateDropWrapper.tsx @@ -21,7 +21,7 @@ import { ReferencedNft, } from "../../../../entities/IDrop"; import { createBreakpoint } from "react-use"; -import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"; +import { $convertToMarkdownString } from "@lexical/markdown"; import { CreateDropType, CreateDropViewType } from "../types"; import { MENTION_TRANSFORMER } from "../lexical/transformers/MentionTransformer"; import { HASHTAG_TRANSFORMER } from "../lexical/transformers/HastagTransformer"; @@ -34,6 +34,7 @@ import { ApiWaveMetadataType } from "../../../../generated/models/ApiWaveMetadat import { ApiWaveParticipationRequirement } from "../../../../generated/models/ApiWaveParticipationRequirement"; import { ProfileMinWithoutSubs } from "../../../../helpers/ProfileTypes"; import { IMAGE_TRANSFORMER } from "../lexical/transformers/ImageTransformer"; +import { SAFE_MARKDOWN_TRANSFORMERS } from "../lexical/transformers/markdownTransformers"; import { QueryKey } from "../../../react-query-wrapper/ReactQueryWrapper"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { WalletValidationError } from "../../../../src/errors/wallet"; @@ -195,7 +196,7 @@ const CreateDropWrapper = forwardRef< const getMarkdown = () => editorState?.read(() => $convertToMarkdownString([ - ...TRANSFORMERS, + ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, IMAGE_TRANSFORMER, diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index 099516766c..add60e253e 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -1,6 +1,9 @@ import { + Children, memo, + useEffect, useMemo, + useRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode, @@ -25,9 +28,192 @@ import { createLinkRenderer, } from "./dropPartMarkdown/linkHandlers"; +import { highlightCodeElement } from "./dropPartMarkdown/highlight"; const BreakComponent = () =>
; +const mergeClassNames = ( + ...classes: Array +): string => classes.filter(Boolean).join(" "); + +const headingClassName = "tw-text-iron-200 tw-break-words word-break"; + +type MarkdownRendererProps = ComponentPropsWithoutRef & + ExtraProps & { children?: ReactNode; className?: string }; + +type MarkdownCodeProps = MarkdownRendererProps<"code"> & { + inline?: boolean; +}; + +type MarkdownComponentsOptions = { + customRenderer: (content: ReactNode | undefined) => ReactNode; + renderParagraph: Components["p"]; + renderAnchor: Components["a"]; + renderImage: Components["img"]; +}; + +const InlineCodeRenderer = ({ + children, + className, + style, + ...props +}: MarkdownCodeProps) => ( + + {children} + +); + +const CodeBlockRenderer = ({ + children, + className, + style, + ...props +}: MarkdownCodeProps) => { + const codeRef = useRef(null); + + const codeText = useMemo(() => { + return Children.toArray(children) + .map((child) => { + if (typeof child === "string" || typeof child === "number") { + return child; + } + + return ""; + }) + .join(""); + }, [children]); + + const language = useMemo(() => { + const match = + typeof className === "string" + ? /language-([\w+-]+)/.exec(className) + : null; + + return match?.[1] ?? null; + }, [className]); + + useEffect(() => { + if (globalThis.window === undefined) { + return; + } + + const element = codeRef.current; + if (!element) { + return; + } + + if (!codeText || codeText.trim() === "") { + return; + } + + void highlightCodeElement(element, language); + }); + + return ( + + {children} + + ); +}; + +const CodeRenderer = ({ inline, ...props }: MarkdownCodeProps) => + inline ? : ; + +const createMarkdownComponents = ({ + customRenderer, + renderParagraph, + renderAnchor, + renderImage, +}: MarkdownComponentsOptions): Components => { + const createHeadingRenderer = (Tag: T) => { + const HeadingRenderer = ({ + children, + className, + ...props + }: MarkdownRendererProps) => { + const TagComponent = Tag; + const mergedProps = { + ...(props as Record), + className: mergeClassNames(headingClassName, className), + }; + + return ( + + {customRenderer(children)} + + ); + }; + + HeadingRenderer.displayName = `MarkdownHeading(${ + typeof Tag === "string" ? Tag : "component" + })`; + + return HeadingRenderer; + }; + + const ListItemRenderer = ({ + children, + className, + ...props + }: MarkdownRendererProps<"li">) => ( +
  • + {customRenderer(children)} +
  • + ); + + const BlockQuoteRenderer = ({ + children, + className, + ...props + }: MarkdownRendererProps<"blockquote">) => ( +
    + {customRenderer(children)} +
    + ); + + return { + h1: createHeadingRenderer("h1"), + h2: createHeadingRenderer("h2"), + h3: createHeadingRenderer("h3"), + h4: createHeadingRenderer("h4"), + h5: createHeadingRenderer("h5"), + p: renderParagraph, + li: ListItemRenderer, + code: CodeRenderer, + a: renderAnchor, + img: renderImage, + br: BreakComponent, + blockquote: BlockQuoteRenderer, + } satisfies Components; +}; + export interface DropPartMarkdownProps { readonly mentionedUsers: Array; readonly referencedNfts: Array; @@ -126,6 +312,8 @@ function DropPartMarkdown({ allowedAttributes: { a: ["href", "title"], img: ["src", "alt", "title"], + code: ["className"], + pre: ["className"], }, }, ], @@ -136,86 +324,13 @@ function DropPartMarkdown({ const remarkPlugins = useMemo(() => [remarkGfm], []); const markdownComponents = useMemo( - () => { - const mergeClassNames = ( - ...classes: Array - ): string => classes.filter(Boolean).join(" "); - - const headingClassName = "tw-text-iron-200 tw-break-words word-break"; - - type MarkdownRendererProps = ComponentPropsWithoutRef & - ExtraProps & { children?: ReactNode; className?: string }; - - const createHeadingRenderer = (Tag: T) => { - const HeadingRenderer = ({ - children, - className, - ...props - }: MarkdownRendererProps) => { - const TagComponent = Tag; - const mergedProps = { - ...(props as Record), - className: mergeClassNames(headingClassName, className), - }; - - return ( - - {customRenderer(children)} - - ); - }; - - HeadingRenderer.displayName = `MarkdownHeading(${typeof Tag === "string" ? Tag : "component"})`; - - return HeadingRenderer; - }; - - return { - h1: createHeadingRenderer("h1"), - h2: createHeadingRenderer("h2"), - h3: createHeadingRenderer("h3"), - h4: createHeadingRenderer("h4"), - h5: createHeadingRenderer("h5"), - p: renderParagraph, - li: ({ children, className, ...props }) => ( -
  • - {customRenderer(children)} -
  • - ), - code: ({ children, className, style, ...props }) => ( - - {customRenderer(children)} - - ), - a: renderAnchor, - img: renderImage, - br: BreakComponent, - blockquote: ({ children, className, ...props }) => ( -
    - {customRenderer(children)} -
    - ), - } satisfies Components; - }, + () => + createMarkdownComponents({ + customRenderer, + renderParagraph, + renderAnchor, + renderImage, + }), [customRenderer, renderAnchor, renderImage, renderParagraph] ); diff --git a/components/drops/view/part/dropPartMarkdown/highlight.ts b/components/drops/view/part/dropPartMarkdown/highlight.ts new file mode 100644 index 0000000000..2ed9d2386e --- /dev/null +++ b/components/drops/view/part/dropPartMarkdown/highlight.ts @@ -0,0 +1,89 @@ +import type { HLJSApi } from "highlight.js"; + +let highlighterPromise: Promise | null = null; + +const loadHighlighter = async (): Promise => { + highlighterPromise ??= (async () => { + const [ + core, + tsModule, + jsModule, + jsonModule, + bashModule, + pythonModule, + goModule, + rustModule, + sqlModule, + ] = await Promise.all([ + import("highlight.js/lib/core"), + import("highlight.js/lib/languages/typescript"), + import("highlight.js/lib/languages/javascript"), + import("highlight.js/lib/languages/json"), + import("highlight.js/lib/languages/bash"), + import("highlight.js/lib/languages/python"), + import("highlight.js/lib/languages/go"), + import("highlight.js/lib/languages/rust"), + import("highlight.js/lib/languages/sql"), + ]); + + const hljs: HLJSApi = core.default; + + hljs.registerLanguage("ts", tsModule.default); + hljs.registerLanguage("tsx", tsModule.default); + hljs.registerLanguage("typescript", tsModule.default); + hljs.registerLanguage("js", jsModule.default); + hljs.registerLanguage("jsx", jsModule.default); + hljs.registerLanguage("javascript", jsModule.default); + hljs.registerLanguage("json", jsonModule.default); + hljs.registerLanguage("bash", bashModule.default); + hljs.registerLanguage("shell", bashModule.default); + hljs.registerLanguage("py", pythonModule.default); + hljs.registerLanguage("python", pythonModule.default); + hljs.registerLanguage("go", goModule.default); + hljs.registerLanguage("golang", goModule.default); + hljs.registerLanguage("rust", rustModule.default); + hljs.registerLanguage("rs", rustModule.default); + hljs.registerLanguage("sql", sqlModule.default); + + return hljs; + })(); + + return highlighterPromise; +}; + +export const highlightCodeElement = async ( + element: HTMLElement, + languageHint?: string | null +) => { + const text = element.textContent ?? ""; + if (!text.trim()) { + return; + } + + const hljs = await loadHighlighter(); + + if (!element.isConnected) { + return; + } + + const hintedLanguage = languageHint?.toLowerCase() ?? null; + const language = hintedLanguage && hljs.getLanguage(hintedLanguage) + ? hintedLanguage + : null; + + element.classList.add("hljs"); + + if (language) { + element.classList.add(`language-${language}`); + hljs.highlightElement(element); + return; + } + + const { value, language: detectedLanguage } = hljs.highlightAuto(text); + element.innerHTML = value; + element.dataset.highlighted = "yes"; + + if (detectedLanguage) { + element.classList.add(`language-${detectedLanguage}`); + } +}; diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx index 0871b36378..d4dd7788b9 100644 --- a/components/waves/CreateDropContent.tsx +++ b/components/waves/CreateDropContent.tsx @@ -23,7 +23,7 @@ import { MentionedUser, ReferencedNft, } from "../../entities/IDrop"; -import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"; +import { $convertToMarkdownString } from "@lexical/markdown"; import { MENTION_TRANSFORMER } from "../drops/create/lexical/transformers/MentionTransformer"; import { HASHTAG_TRANSFORMER } from "../drops/create/lexical/transformers/HastagTransformer"; import { IMAGE_TRANSFORMER } from "../drops/create/lexical/transformers/ImageTransformer"; @@ -51,6 +51,7 @@ import { ApiReplyToDropResponse } from "../../generated/models/ApiReplyToDropRes import { CreateDropDropModeToggle } from "./CreateDropDropModeToggle"; import { CreateDropSubmit } from "./CreateDropSubmit"; import { DropPrivileges } from "../../hooks/useDropPriviledges"; +import { SAFE_MARKDOWN_TRANSFORMERS } from "@/components/drops/create/lexical/transformers/markdownTransformers"; import { ApiWaveCreditType } from "../../generated/models/ApiWaveCreditType"; import { useDropMetadata, generateMetadataId } from "./hooks/useDropMetadata"; @@ -489,7 +490,7 @@ const CreateDropContent: React.FC = ({ () => editorState?.read(() => $convertToMarkdownString([ - ...TRANSFORMERS, + ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, IMAGE_TRANSFORMER, diff --git a/components/waves/CreateDropInput.tsx b/components/waves/CreateDropInput.tsx index 69878eb9d3..10d765bd23 100644 --- a/components/waves/CreateDropInput.tsx +++ b/components/waves/CreateDropInput.tsx @@ -24,7 +24,6 @@ import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; -import { TRANSFORMERS } from "@lexical/markdown"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { ListNode, ListItemNode } from "@lexical/list"; @@ -59,6 +58,8 @@ import CreateDropEmojiPicker from "./CreateDropEmojiPicker"; import useCapacitor from "../../hooks/useCapacitor"; import EmojiPlugin from "../drops/create/lexical/plugins/emoji/EmojiPlugin"; import { EmojiNode } from "../drops/create/lexical/nodes/EmojiNode"; +import { SAFE_MARKDOWN_TRANSFORMERS } from "@/components/drops/create/lexical/transformers/markdownTransformers"; +import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; export interface CreateDropInputHandles { clearEditorState: () => void; @@ -287,7 +288,8 @@ const CreateDropInput = forwardRef< - + + diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 9c90f6683b..27ce1507dc 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -11,11 +11,7 @@ import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; -import { - TRANSFORMERS, - $convertFromMarkdownString, - $convertToMarkdownString, -} from "@lexical/markdown"; +import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"; import { $getRoot, EditorState, @@ -23,6 +19,11 @@ import { KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, TextNode, + $createParagraphNode, + $createTextNode, + $isElementNode, + type LexicalNode, + type RootNode, } from "lexical"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; @@ -30,7 +31,7 @@ import { ListNode, ListItemNode } from "@lexical/list"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { ListPlugin } from "@lexical/react/LexicalListPlugin"; -import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { CodeHighlightNode, CodeNode, $isCodeNode } from "@lexical/code"; import { AutoLinkNode, LinkNode } from "@lexical/link"; import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; @@ -51,19 +52,62 @@ import CreateDropEmojiPicker from "../CreateDropEmojiPicker"; import useDeviceInfo from "../../../hooks/useDeviceInfo"; import EmojiPlugin from "../../drops/create/lexical/plugins/emoji/EmojiPlugin"; import { EmojiNode } from "../../drops/create/lexical/nodes/EmojiNode"; +import { SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE } from "@/components/drops/create/lexical/transformers/markdownTransformers"; +import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; interface EditDropLexicalProps { - initialContent: string; - initialMentions: ApiDropMentionedUser[]; - waveId: string | null; - isSaving: boolean; - onSave: (content: string, mentions: ApiDropMentionedUser[]) => void; - onCancel: () => void; + readonly initialContent: string; + readonly initialMentions: ApiDropMentionedUser[]; + readonly waveId: string | null; + readonly isSaving: boolean; + readonly onSave: (content: string, mentions: ApiDropMentionedUser[]) => void; + readonly onCancel: () => void; } const MAX_MENTION_RECONSTRUCTION_PASSES = 20; -// Plugin to set initial content from markdown +const EDIT_MARKDOWN_TRANSFORMERS = [ + ...SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE, + MENTION_TRANSFORMER, + HASHTAG_TRANSFORMER, +]; + +const convertCodeNodesToFences = (root: RootNode) => { + const stack: LexicalNode[] = [...root.getChildren()]; + + while (stack.length > 0) { + const node = stack.pop(); + if (!node) continue; + + if ($isCodeNode(node)) { + const language = node.getLanguage?.() ?? ""; + const safeLanguage = language.trim().replaceAll(/[`\n\r]/g, ""); + const codeText = node.getTextContent(); + const normalizedCode = codeText.endsWith("\n") + ? codeText + : `${codeText}\n`; + const maxExistingFence = (codeText.match(/`+/g) ?? []).reduce( + (max, match) => Math.max(max, match.length), + 0 + ); + const fenceLength = Math.max(3, maxExistingFence + 1); + const fence = "`".repeat(fenceLength); + const openFence = safeLanguage ? `${fence}${safeLanguage}` : fence; + const fencedMarkdown = `${openFence}\n${normalizedCode}${fence}`; + + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(fencedMarkdown)); + node.replace(paragraph); + + continue; + } + + if ($isElementNode(node)) { + stack.push(...node.getChildren()); + } + } +}; + function reconstructSplitMention( currentNode: any, nextNode: any, @@ -82,14 +126,12 @@ function reconstructSplitMention( const currentText = currentNode.getTextContent(); const nextText = nextNode.getTextContent(); - // Calculate text before and after mention const beforeMention = currentText.substring( 0, currentText.length - mentionStart[0].length ); const afterMention = nextText.substring(mentionEnd[0].length); - // Update or remove current node if (beforeMention) { currentNode.setTextContent(beforeMention); currentNode.insertAfter(mentionNode); @@ -98,7 +140,6 @@ function reconstructSplitMention( nextNode.insertBefore(mentionNode); } - // Update or remove next node if (afterMention) { nextNode.setTextContent(afterMention); } else { @@ -116,7 +157,6 @@ function processSplitMentions(textNodes: Array): boolean { const currentText = currentNode.getTextContent(); const nextText = nextNode.getTextContent(); - // Check for @[ at end of current node and word] at start of next const mentionStart = currentText.match(/@\[\w*$/); const mentionEnd = nextText.match(/^\w*\]/); @@ -125,7 +165,7 @@ function processSplitMentions(textNodes: Array): boolean { if ( reconstructSplitMention(currentNode, nextNode, mentionStart, mentionEnd) ) { - return true; // Tree changed; caller should re-run with fresh text nodes + return true; } } catch (error) { console.warn("Failed to reconstruct split mention", error); @@ -141,14 +181,10 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { useEffect(() => { editor.update(() => { - $convertFromMarkdownString(initialContent, [ - ...TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - ]); + $convertFromMarkdownString(initialContent, EDIT_MARKDOWN_TRANSFORMERS); - // Post-process: reconstruct mentions split across text nodes const root = $getRoot(); + convertCodeNodesToFences(root); let needsAnotherPass = true; let passCount = 0; @@ -158,8 +194,6 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { ) { const textNodes = root.getAllTextNodes(); - // If any text node still has the full @[handle] pattern, defer to the - // mention transformer rather than trying to stitch pieces together. const hasUnprocessedMentions = textNodes.some((node) => /@\[\w+\]/.test(node.getTextContent()) ); @@ -185,39 +219,32 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { return null; } -// Plugin to handle keyboard shortcuts -// Plugin to handle focus on mount function FocusPlugin({ isApp }: { isApp: boolean }) { const [editor] = useLexicalComposerContext(); useEffect(() => { const focusEditor = () => { - // Try Lexical's focus first editor.focus(); - - // Also try DOM focus as fallback requestAnimationFrame(() => { const contentEditable = document.querySelector( '[contenteditable="true"]' ) as HTMLElement; if (contentEditable && document.activeElement !== contentEditable) { contentEditable.focus(); - contentEditable.click(); // For mobile keyboard + contentEditable.click(); } }); }; if (isApp) { - // Multiple timing strategies for mobile reliability const timeouts = [ - setTimeout(focusEditor, 100), // Quick attempt - setTimeout(focusEditor, 350), // After menu close - setTimeout(focusEditor, 600), // Final attempt + setTimeout(focusEditor, 100), + setTimeout(focusEditor, 350), + setTimeout(focusEditor, 600), ]; return () => timeouts.forEach(clearTimeout); } else { - // Desktop: immediate focus focusEditor(); } }, [editor, isApp]); @@ -253,26 +280,18 @@ function KeyboardPlugin({ const removeEnterListener = editor.registerCommand( KEY_ENTER_COMMAND, (event) => { - // Check if mentions dropdown is open if (mentionsRef.current?.isMentionsOpen()) { - // Let the mentions plugin handle the Enter key return false; } if (event?.shiftKey) { - // Allow Shift+Enter for new lines return false; } if (!isSaving) { - // Check if content has changed (similar to original logic) editor.getEditorState().read(() => { - const currentMarkdown = $convertToMarkdownString([ - ...TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - ]); - // If no changes, just cancel (silent exit) + const currentMarkdown = + $convertToMarkdownString(EDIT_MARKDOWN_TRANSFORMERS); if (currentMarkdown.trim() === initialContent.trim()) { onCancel(); } else { @@ -343,7 +362,6 @@ const EditDropLexical: React.FC = ({ }; setMentionedUsers((prev) => { - // Avoid duplicates if ( prev.some( (m) => m.mentioned_profile_id === newMention.mentioned_profile_id @@ -361,13 +379,8 @@ const EditDropLexical: React.FC = ({ if (!editorState) return; editorState.read(() => { - const markdown = $convertToMarkdownString([ - ...TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - ]); + const markdown = $convertToMarkdownString(EDIT_MARKDOWN_TRANSFORMERS); - // If no changes, silently exit edit mode without API call if (markdown.trim() === initialContent.trim()) { onCancel(); return; @@ -403,7 +416,10 @@ const EditDropLexical: React.FC = ({ /> - + +