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 = ({
/>
-
+
+