From ed78816676e38dc54c477440ae5abf28a24da389 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 09:21:49 +0300 Subject: [PATCH 01/22] wip Signed-off-by: Simo --- .../lexical/plugins/PlainTextPastePlugin.tsx | 52 +++++++++++++++++++ .../transformers/markdownTransformers.ts | 17 ++++++ .../drops/create/utils/CreateDropContent.tsx | 9 ++-- .../drops/create/utils/CreateDropWrapper.tsx | 5 +- components/waves/CreateDropContent.tsx | 5 +- components/waves/CreateDropInput.tsx | 6 ++- components/waves/drops/EditDropLexical.tsx | 17 +++--- 7 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx create mode 100644 components/drops/create/lexical/transformers/markdownTransformers.ts diff --git a/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx new file mode 100644 index 0000000000..f2c47bc882 --- /dev/null +++ b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx @@ -0,0 +1,52 @@ +"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; + } + + // Let other handlers process file pastes (e.g. images) + 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/transformers/markdownTransformers.ts b/components/drops/create/lexical/transformers/markdownTransformers.ts new file mode 100644 index 0000000000..50a4148941 --- /dev/null +++ b/components/drops/create/lexical/transformers/markdownTransformers.ts @@ -0,0 +1,17 @@ +import { TRANSFORMERS } from "@lexical/markdown"; + +const UNDERSCORE_TAGS = new Set(["__", "___", "_"]); + +// Strip underscore-triggered shortcuts so pasting code keeps underscores intact. +export const SAFE_MARKDOWN_TRANSFORMERS = TRANSFORMERS.filter((transformer) => { + if (typeof transformer !== "object" || transformer === null) { + return true; + } + + const maybeTag = (transformer as { tag?: unknown }).tag; + if (typeof maybeTag !== "string") { + return true; + } + + return !UNDERSCORE_TAGS.has(maybeTag); +}); 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/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..399ab1c158 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, @@ -51,6 +47,8 @@ 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 } from "@/components/drops/create/lexical/transformers/markdownTransformers"; +import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; interface EditDropLexicalProps { initialContent: string; @@ -142,7 +140,7 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { useEffect(() => { editor.update(() => { $convertFromMarkdownString(initialContent, [ - ...TRANSFORMERS, + ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, ]); @@ -268,7 +266,7 @@ function KeyboardPlugin({ // Check if content has changed (similar to original logic) editor.getEditorState().read(() => { const currentMarkdown = $convertToMarkdownString([ - ...TRANSFORMERS, + ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, ]); @@ -362,7 +360,7 @@ const EditDropLexical: React.FC = ({ editorState.read(() => { const markdown = $convertToMarkdownString([ - ...TRANSFORMERS, + ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, ]); @@ -403,7 +401,8 @@ const EditDropLexical: React.FC = ({ /> - + + Date: Wed, 1 Oct 2025 09:50:12 +0300 Subject: [PATCH 02/22] wip Signed-off-by: Simo --- .../drops/create/lexical/lexical.styles.scss | 10 +++++ .../plugins/hashtags/HashtagsPlugin.tsx | 37 ++++++++++++++++++- .../plugins/mentions/MentionsPlugin.tsx | 37 ++++++++++++++++++- .../drops/view/part/DropPartMarkdown.tsx | 2 +- 4 files changed, 83 insertions(+), 3 deletions(-) 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/hashtags/HashtagsPlugin.tsx b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx index 767f8ac515..f88bbfd494 100644 --- a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx +++ b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx @@ -7,7 +7,9 @@ import { MenuTextMatch, useBasicTypeaheadTriggerMatch, } from "@lexical/react/LexicalTypeaheadMenuPlugin"; -import { TextNode } from "lexical"; +import { TextNode, $getSelection, $isRangeSelection } from "lexical"; +import type { LexicalNode } from "lexical"; +import { $isCodeNode } from "@lexical/code"; import { forwardRef, useCallback, @@ -238,6 +240,39 @@ const NewHashtagsPlugin = forwardRef< const checkForHashtagMatch = useCallback( (text: string) => { + const shouldSkip = 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); + }); + + if (shouldSkip) { + 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..8047a200be 100644 --- a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx +++ b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx @@ -7,7 +7,9 @@ import { MenuTextMatch, useBasicTypeaheadTriggerMatch, } from "@lexical/react/LexicalTypeaheadMenuPlugin"; -import { TextNode } from "lexical"; +import { TextNode, $getSelection, $isRangeSelection } from "lexical"; +import type { LexicalNode } from "lexical"; +import { $isCodeNode } from "@lexical/code"; import { forwardRef, useCallback, @@ -210,6 +212,39 @@ const NewMentionsPlugin = forwardRef< const checkForMentionMatch = useCallback( (text: string) => { + const shouldSkip = 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); + }); + + if (shouldSkip) { + return null; + } + const slashMatch = checkForSlashTriggerMatch(text, editor); if (slashMatch !== null) { return null; diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index 099516766c..1d7fe5a51c 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -197,7 +197,7 @@ function DropPartMarkdown({ className )} > - {customRenderer(children)} + {children} ), a: renderAnchor, From bc4655aecfcc2559f0d021b5596b2c45f7b29d66 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 10:19:43 +0300 Subject: [PATCH 03/22] wip Signed-off-by: Simo --- .../transformers/markdownTransformers.ts | 36 ++++++++-- components/waves/drops/EditDropLexical.tsx | 70 ++++++++++++++----- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/components/drops/create/lexical/transformers/markdownTransformers.ts b/components/drops/create/lexical/transformers/markdownTransformers.ts index 50a4148941..f0b500788a 100644 --- a/components/drops/create/lexical/transformers/markdownTransformers.ts +++ b/components/drops/create/lexical/transformers/markdownTransformers.ts @@ -1,10 +1,14 @@ -import { TRANSFORMERS } from "@lexical/markdown"; +import { TRANSFORMERS, type Transformer } from "@lexical/markdown"; const UNDERSCORE_TAGS = new Set(["__", "___", "_"]); -// Strip underscore-triggered shortcuts so pasting code keeps underscores intact. -export const SAFE_MARKDOWN_TRANSFORMERS = TRANSFORMERS.filter((transformer) => { - if (typeof transformer !== "object" || transformer === null) { +const isObjectTransformer = ( + transformer: unknown +): transformer is Transformer => + typeof transformer === "object" && transformer !== null; + +const BASE_SAFE_TRANSFORMERS = TRANSFORMERS.filter((transformer) => { + if (!isObjectTransformer(transformer)) { return true; } @@ -15,3 +19,27 @@ export const SAFE_MARKDOWN_TRANSFORMERS = TRANSFORMERS.filter((transformer) => { return !UNDERSCORE_TAGS.has(maybeTag); }); + +const isCodeTransformer = (transformer: Transformer): boolean => { + if (!Array.isArray(transformer.dependencies)) { + return false; + } + + return transformer.dependencies.some((dependency) => { + if (!dependency || typeof dependency.getType !== "function") { + return false; + } + + return dependency.getType() === "code"; + }); +}; + +// Strip underscore-triggered shortcuts so pasting code keeps underscores intact. +export const SAFE_MARKDOWN_TRANSFORMERS = BASE_SAFE_TRANSFORMERS; + +// Variant that keeps fenced code fences as raw markdown (useful for edit mode). +export const SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE = + BASE_SAFE_TRANSFORMERS.filter( + (transformer) => + !isObjectTransformer(transformer) || !isCodeTransformer(transformer) + ); diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 399ab1c158..ea989d6152 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -19,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"; @@ -26,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"; @@ -47,7 +52,7 @@ 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 } from "@/components/drops/create/lexical/transformers/markdownTransformers"; +import { SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE } from "@/components/drops/create/lexical/transformers/markdownTransformers"; import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin"; interface EditDropLexicalProps { @@ -61,6 +66,43 @@ interface EditDropLexicalProps { const MAX_MENTION_RECONSTRUCTION_PASSES = 20; +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 trimmedLanguage = language.trim(); + const codeText = node.getTextContent(); + const normalizedCode = codeText.endsWith("\n") + ? codeText + : `${codeText}\n`; + const fence = "```"; + const openFence = trimmedLanguage ? `${fence}${trimmedLanguage}` : 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()); + } + } +}; + // Plugin to set initial content from markdown function reconstructSplitMention( currentNode: any, @@ -139,14 +181,11 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { useEffect(() => { editor.update(() => { - $convertFromMarkdownString(initialContent, [ - ...SAFE_MARKDOWN_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; @@ -265,11 +304,8 @@ function KeyboardPlugin({ if (!isSaving) { // Check if content has changed (similar to original logic) editor.getEditorState().read(() => { - const currentMarkdown = $convertToMarkdownString([ - ...SAFE_MARKDOWN_TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - ]); + const currentMarkdown = + $convertToMarkdownString(EDIT_MARKDOWN_TRANSFORMERS); // If no changes, just cancel (silent exit) if (currentMarkdown.trim() === initialContent.trim()) { onCancel(); @@ -359,11 +395,7 @@ const EditDropLexical: React.FC = ({ if (!editorState) return; editorState.read(() => { - const markdown = $convertToMarkdownString([ - ...SAFE_MARKDOWN_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()) { @@ -401,7 +433,9 @@ const EditDropLexical: React.FC = ({ /> - + From e0f62690240e4aaa1ec270430a00217aa009f10c Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 11:51:00 +0300 Subject: [PATCH 04/22] wip Signed-off-by: Simo --- .../drops/view/part/DropPartMarkdown.tsx | 88 ++++++++++++++++--- .../view/part/dropPartMarkdown/highlight.ts | 70 +++++++++++++++ styles/globals.scss | 1 + 3 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 components/drops/view/part/dropPartMarkdown/highlight.ts diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index 1d7fe5a51c..cd11c156a5 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -1,6 +1,8 @@ import { memo, + useEffect, useMemo, + useRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode, @@ -25,6 +27,7 @@ import { createLinkRenderer, } from "./dropPartMarkdown/linkHandlers"; +import { highlightCodeElement } from "./dropPartMarkdown/highlight"; const BreakComponent = () =>
; @@ -126,6 +129,8 @@ function DropPartMarkdown({ allowedAttributes: { a: ["href", "title"], img: ["src", "alt", "title"], + code: ["className"], + pre: ["className"], }, }, ], @@ -170,6 +175,71 @@ function DropPartMarkdown({ return HeadingRenderer; }; + type MarkdownCodeProps = MarkdownRendererProps<"code"> & { + inline?: boolean; + }; + + const InlineCodeRenderer = ({ + children, + className, + style, + ...props + }: MarkdownCodeProps) => ( + + {children} + + ); + + const CodeBlockRenderer = ({ + children, + className, + style, + ...props + }: MarkdownCodeProps) => { + const codeRef = useRef(null); + + const language = useMemo(() => { + const match = typeof className === "string" + ? /language-([\w+-]+)/.exec(className) + : null; + return match?.[1] ?? null; + }, [className]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const element = codeRef.current; + if (!element) { + return; + } + + void highlightCodeElement(element, language); + }, [language, children]); + + return ( + + {children} + + ); + }; + return { h1: createHeadingRenderer("h1"), h2: createHeadingRenderer("h2"), @@ -188,18 +258,12 @@ function DropPartMarkdown({ {customRenderer(children)} ), - code: ({ children, className, style, ...props }) => ( - - {children} - - ), + code: ({ inline, ...props }: MarkdownCodeProps) => + inline ? ( + + ) : ( + + ), a: renderAnchor, img: renderImage, br: BreakComponent, diff --git a/components/drops/view/part/dropPartMarkdown/highlight.ts b/components/drops/view/part/dropPartMarkdown/highlight.ts new file mode 100644 index 0000000000..4ad7d22318 --- /dev/null +++ b/components/drops/view/part/dropPartMarkdown/highlight.ts @@ -0,0 +1,70 @@ +import type { HLJSApi } from "highlight.js"; + +let highlighterPromise: Promise | null = null; + +const loadHighlighter = async (): Promise => { + if (!highlighterPromise) { + highlighterPromise = (async () => { + const [core, tsModule, jsModule, jsonModule, bashModule] = 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"), + ]); + + 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); + + 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.setAttribute("data-highlighted", "yes"); + + if (detectedLanguage) { + element.classList.add(`language-${detectedLanguage}`); + } +}; diff --git a/styles/globals.scss b/styles/globals.scss index df3ce50fd8..1c706923aa 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -3,6 +3,7 @@ @use "fonts.scss"; @import "react-toggle/style.css"; @import "react-tooltip/dist/react-tooltip.css"; +@import "highlight.js/styles/vs2015.css"; @tailwind base; @tailwind components; From 63595fc132a57f007dba24d8c0d1670aa6bdae08 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 12:12:04 +0300 Subject: [PATCH 05/22] wip Signed-off-by: Simo --- .../transformers/markdownTransformers.ts | 13 ++++--- components/waves/drops/EditDropLexical.tsx | 34 ++++++------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/components/drops/create/lexical/transformers/markdownTransformers.ts b/components/drops/create/lexical/transformers/markdownTransformers.ts index f0b500788a..c4316927a9 100644 --- a/components/drops/create/lexical/transformers/markdownTransformers.ts +++ b/components/drops/create/lexical/transformers/markdownTransformers.ts @@ -21,16 +21,21 @@ const BASE_SAFE_TRANSFORMERS = TRANSFORMERS.filter((transformer) => { }); const isCodeTransformer = (transformer: Transformer): boolean => { - if (!Array.isArray(transformer.dependencies)) { + const dependencies = (transformer as { dependencies?: unknown }).dependencies; + if (!Array.isArray(dependencies)) { return false; } - return transformer.dependencies.some((dependency) => { - if (!dependency || typeof dependency.getType !== "function") { + return dependencies.some((dependency) => { + if ( + !dependency || + typeof dependency !== "object" || + typeof (dependency as { getType?: unknown }).getType !== "function" + ) { return false; } - return dependency.getType() === "code"; + return (dependency as { getType: () => string }).getType() === "code"; }); }; diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index ea989d6152..adcaa56976 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -56,12 +56,12 @@ import { SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE } from "@/components/drops/crea 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; @@ -195,8 +195,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()) ); @@ -222,39 +220,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]); @@ -290,23 +281,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(EDIT_MARKDOWN_TRANSFORMERS); - // If no changes, just cancel (silent exit) if (currentMarkdown.trim() === initialContent.trim()) { onCancel(); } else { From d4cf5facd99920c1b243d5e99012ec06f818ad78 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 12:24:33 +0300 Subject: [PATCH 06/22] wip Signed-off-by: Simo --- .../view/part/dropPartMarkdown/highlight.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/components/drops/view/part/dropPartMarkdown/highlight.ts b/components/drops/view/part/dropPartMarkdown/highlight.ts index 4ad7d22318..54161cd5fb 100644 --- a/components/drops/view/part/dropPartMarkdown/highlight.ts +++ b/components/drops/view/part/dropPartMarkdown/highlight.ts @@ -5,12 +5,26 @@ let highlighterPromise: Promise | null = null; const loadHighlighter = async (): Promise => { if (!highlighterPromise) { highlighterPromise = (async () => { - const [core, tsModule, jsModule, jsonModule, bashModule] = await Promise.all([ + 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; @@ -24,6 +38,13 @@ const loadHighlighter = async (): Promise => { 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; })(); From 799816a6a2a8a74ae95dc37b31ca2d5623f16c36 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:06:55 +0300 Subject: [PATCH 07/22] wip Signed-off-by: Simo --- .../plugins/hashtags/HashtagsPlugin.tsx | 36 ++----------------- .../plugins/mentions/MentionsPlugin.tsx | 36 ++----------------- .../lexical/utils/codeContextDetection.ts | 34 ++++++++++++++++++ 3 files changed, 40 insertions(+), 66 deletions(-) create mode 100644 components/drops/create/lexical/utils/codeContextDetection.ts diff --git a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx index f88bbfd494..65f5af36f6 100644 --- a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx +++ b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx @@ -7,9 +7,7 @@ import { MenuTextMatch, useBasicTypeaheadTriggerMatch, } from "@lexical/react/LexicalTypeaheadMenuPlugin"; -import { TextNode, $getSelection, $isRangeSelection } from "lexical"; -import type { LexicalNode } from "lexical"; -import { $isCodeNode } from "@lexical/code"; +import { TextNode } from "lexical"; import { forwardRef, useCallback, @@ -26,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 = "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;"; @@ -240,36 +239,7 @@ const NewHashtagsPlugin = forwardRef< const checkForHashtagMatch = useCallback( (text: string) => { - const shouldSkip = 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); - }); - - if (shouldSkip) { + if (isInCodeContext(editor)) { return null; } diff --git a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx index 8047a200be..3184b85c42 100644 --- a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx +++ b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx @@ -7,9 +7,7 @@ import { MenuTextMatch, useBasicTypeaheadTriggerMatch, } from "@lexical/react/LexicalTypeaheadMenuPlugin"; -import { TextNode, $getSelection, $isRangeSelection } from "lexical"; -import type { LexicalNode } from "lexical"; -import { $isCodeNode } from "@lexical/code"; +import { TextNode } from "lexical"; import { forwardRef, useCallback, @@ -25,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 = "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;"; @@ -212,36 +211,7 @@ const NewMentionsPlugin = forwardRef< const checkForMentionMatch = useCallback( (text: string) => { - const shouldSkip = 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); - }); - - if (shouldSkip) { + if (isInCodeContext(editor)) { return null; } 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); + }); +} From 9daf176d7232f6844d61424064e272324c8bcc25 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:11:12 +0300 Subject: [PATCH 08/22] wip Signed-off-by: Simo --- components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx | 1 - .../drops/create/lexical/transformers/markdownTransformers.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx index f2c47bc882..0478b8843a 100644 --- a/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx +++ b/components/drops/create/lexical/plugins/PlainTextPastePlugin.tsx @@ -23,7 +23,6 @@ export default function PlainTextPastePlugin(): null { return false; } - // Let other handlers process file pastes (e.g. images) if (clipboardData.files.length > 0) { return false; } diff --git a/components/drops/create/lexical/transformers/markdownTransformers.ts b/components/drops/create/lexical/transformers/markdownTransformers.ts index c4316927a9..2a054f02b7 100644 --- a/components/drops/create/lexical/transformers/markdownTransformers.ts +++ b/components/drops/create/lexical/transformers/markdownTransformers.ts @@ -39,7 +39,6 @@ const isCodeTransformer = (transformer: Transformer): boolean => { }); }; -// Strip underscore-triggered shortcuts so pasting code keeps underscores intact. export const SAFE_MARKDOWN_TRANSFORMERS = BASE_SAFE_TRANSFORMERS; // Variant that keeps fenced code fences as raw markdown (useful for edit mode). From e48b2eaba36ba44d80ad259bee0d98b04056ec7b Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:14:50 +0300 Subject: [PATCH 09/22] wip Signed-off-by: Simo --- .../lexical/transformers/markdownTransformers.ts | 1 - components/waves/drops/EditDropLexical.tsx | 10 +--------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/components/drops/create/lexical/transformers/markdownTransformers.ts b/components/drops/create/lexical/transformers/markdownTransformers.ts index 2a054f02b7..ff80fddd53 100644 --- a/components/drops/create/lexical/transformers/markdownTransformers.ts +++ b/components/drops/create/lexical/transformers/markdownTransformers.ts @@ -41,7 +41,6 @@ const isCodeTransformer = (transformer: Transformer): boolean => { export const SAFE_MARKDOWN_TRANSFORMERS = BASE_SAFE_TRANSFORMERS; -// Variant that keeps fenced code fences as raw markdown (useful for edit mode). export const SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE = BASE_SAFE_TRANSFORMERS.filter( (transformer) => diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index adcaa56976..3fb95c5779 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -103,7 +103,6 @@ const convertCodeNodesToFences = (root: RootNode) => { } }; -// Plugin to set initial content from markdown function reconstructSplitMention( currentNode: any, nextNode: any, @@ -122,14 +121,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); @@ -138,7 +135,6 @@ function reconstructSplitMention( nextNode.insertBefore(mentionNode); } - // Update or remove next node if (afterMention) { nextNode.setTextContent(afterMention); } else { @@ -156,7 +152,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*\]/); @@ -165,7 +160,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); @@ -183,7 +178,6 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { editor.update(() => { $convertFromMarkdownString(initialContent, EDIT_MARKDOWN_TRANSFORMERS); - // Post-process: reconstruct mentions split across text nodes const root = $getRoot(); convertCodeNodesToFences(root); @@ -363,7 +357,6 @@ const EditDropLexical: React.FC = ({ }; setMentionedUsers((prev) => { - // Avoid duplicates if ( prev.some( (m) => m.mentioned_profile_id === newMention.mentioned_profile_id @@ -383,7 +376,6 @@ const EditDropLexical: React.FC = ({ editorState.read(() => { const markdown = $convertToMarkdownString(EDIT_MARKDOWN_TRANSFORMERS); - // If no changes, silently exit edit mode without API call if (markdown.trim() === initialContent.trim()) { onCancel(); return; From 2c95cdca55a21f782010e5785680a9c4bfaa968e Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:23:26 +0300 Subject: [PATCH 10/22] wip Signed-off-by: Simo --- .../drops/view/part/DropPartMarkdown.tsx | 150 +++++++++--------- 1 file changed, 76 insertions(+), 74 deletions(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index cd11c156a5..f49fcfd707 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -31,6 +31,82 @@ 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; +}; + +const InlineCodeRenderer = ({ + children, + className, + style, + ...props +}: MarkdownCodeProps) => ( + + {children} + +); + +const CodeBlockRenderer = ({ + children, + className, + style, + ...props +}: MarkdownCodeProps) => { + const codeRef = useRef(null); + + const language = useMemo(() => { + const match = + typeof className === "string" + ? /language-([\w+-]+)/.exec(className) + : null; + + return match?.[1] ?? null; + }, [className]); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const element = codeRef.current; + if (!element) { + return; + } + + void highlightCodeElement(element, language); + }, [language, children]); + + return ( + + {children} + + ); +}; + export interface DropPartMarkdownProps { readonly mentionedUsers: Array; readonly referencedNfts: Array; @@ -142,15 +218,6 @@ function DropPartMarkdown({ 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, @@ -175,71 +242,6 @@ function DropPartMarkdown({ return HeadingRenderer; }; - type MarkdownCodeProps = MarkdownRendererProps<"code"> & { - inline?: boolean; - }; - - const InlineCodeRenderer = ({ - children, - className, - style, - ...props - }: MarkdownCodeProps) => ( - - {children} - - ); - - const CodeBlockRenderer = ({ - children, - className, - style, - ...props - }: MarkdownCodeProps) => { - const codeRef = useRef(null); - - const language = useMemo(() => { - const match = typeof className === "string" - ? /language-([\w+-]+)/.exec(className) - : null; - return match?.[1] ?? null; - }, [className]); - - useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const element = codeRef.current; - if (!element) { - return; - } - - void highlightCodeElement(element, language); - }, [language, children]); - - return ( - - {children} - - ); - }; - return { h1: createHeadingRenderer("h1"), h2: createHeadingRenderer("h2"), From 9689a758876e2b8fc821f91d5dbfb9ccfefb1948 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:28:32 +0300 Subject: [PATCH 11/22] wip Signed-off-by: Simo --- .../drops/view/part/DropPartMarkdown.tsx | 162 +++++++++++------- 1 file changed, 97 insertions(+), 65 deletions(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index f49fcfd707..41e22c01fe 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -44,6 +44,13 @@ 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, @@ -107,6 +114,89 @@ const CodeBlockRenderer = ({ ); }; +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 CodeRenderer = ({ inline, ...props }: MarkdownCodeProps) => + inline ? : ; + + 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; @@ -217,71 +307,13 @@ function DropPartMarkdown({ const remarkPlugins = useMemo(() => [remarkGfm], []); const markdownComponents = useMemo( - () => { - 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: ({ inline, ...props }: MarkdownCodeProps) => - inline ? ( - - ) : ( - - ), - a: renderAnchor, - img: renderImage, - br: BreakComponent, - blockquote: ({ children, className, ...props }) => ( -
    - {customRenderer(children)} -
    - ), - } satisfies Components; - }, + () => + createMarkdownComponents({ + customRenderer, + renderParagraph, + renderAnchor, + renderImage, + }), [customRenderer, renderAnchor, renderImage, renderParagraph] ); From f60b82ebe41c84b914913decc983afc76f1c3dfc Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:31:06 +0300 Subject: [PATCH 12/22] wip Signed-off-by: Simo --- components/drops/view/part/DropPartMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index 41e22c01fe..bfcf9d99d6 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -87,7 +87,7 @@ const CodeBlockRenderer = ({ }, [className]); useEffect(() => { - if (typeof window === "undefined") { + if (typeof globalThis.window === "undefined") { return; } From 501241fcc74b2984663907dbdd42b1a169fbeef9 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:34:03 +0300 Subject: [PATCH 13/22] wip Signed-off-by: Simo --- components/drops/view/part/DropPartMarkdown.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index bfcf9d99d6..f805ed91dd 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -114,6 +114,9 @@ const CodeBlockRenderer = ({ ); }; +const CodeRenderer = ({ inline, ...props }: MarkdownCodeProps) => + inline ? : ; + const createMarkdownComponents = ({ customRenderer, renderParagraph, @@ -162,9 +165,6 @@ const createMarkdownComponents = ({ ); - const CodeRenderer = ({ inline, ...props }: MarkdownCodeProps) => - inline ? : ; - const BlockQuoteRenderer = ({ children, className, From 74ee8d7b2a2090233d2621196cdb9599636501d8 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:36:45 +0300 Subject: [PATCH 14/22] wip Signed-off-by: Simo --- .../view/part/dropPartMarkdown/highlight.ts | 84 +++++++++---------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/components/drops/view/part/dropPartMarkdown/highlight.ts b/components/drops/view/part/dropPartMarkdown/highlight.ts index 54161cd5fb..7988186eec 100644 --- a/components/drops/view/part/dropPartMarkdown/highlight.ts +++ b/components/drops/view/part/dropPartMarkdown/highlight.ts @@ -3,52 +3,50 @@ import type { HLJSApi } from "highlight.js"; let highlighterPromise: Promise | null = null; const loadHighlighter = async (): Promise => { - if (!highlighterPromise) { - 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"), - ]); + 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; + 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); + 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 hljs; + })(); return highlighterPromise; }; From 63a995f6aa05c5ba558ca5f5944c8d9d19df6ee4 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:38:44 +0300 Subject: [PATCH 15/22] wip Signed-off-by: Simo --- components/drops/view/part/dropPartMarkdown/highlight.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/drops/view/part/dropPartMarkdown/highlight.ts b/components/drops/view/part/dropPartMarkdown/highlight.ts index 7988186eec..2ed9d2386e 100644 --- a/components/drops/view/part/dropPartMarkdown/highlight.ts +++ b/components/drops/view/part/dropPartMarkdown/highlight.ts @@ -81,7 +81,7 @@ export const highlightCodeElement = async ( const { value, language: detectedLanguage } = hljs.highlightAuto(text); element.innerHTML = value; - element.setAttribute("data-highlighted", "yes"); + element.dataset.highlighted = "yes"; if (detectedLanguage) { element.classList.add(`language-${detectedLanguage}`); From 30fbf910878b584baa7486d67bb9b2bd43df5125 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:42:07 +0300 Subject: [PATCH 16/22] wip Signed-off-by: Simo --- components/waves/drops/EditDropLexical.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 3fb95c5779..50c4c51451 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -81,13 +81,13 @@ const convertCodeNodesToFences = (root: RootNode) => { if ($isCodeNode(node)) { const language = node.getLanguage?.() ?? ""; - const trimmedLanguage = language.trim(); + const safeLanguage = language.trim().replace(/[`\n\r]/g, ""); const codeText = node.getTextContent(); const normalizedCode = codeText.endsWith("\n") ? codeText : `${codeText}\n`; const fence = "```"; - const openFence = trimmedLanguage ? `${fence}${trimmedLanguage}` : fence; + const openFence = safeLanguage ? `${fence}${safeLanguage}` : fence; const fencedMarkdown = `${openFence}\n${normalizedCode}${fence}`; const paragraph = $createParagraphNode(); From 99f80b19ebd076f99336d974c3c723783e0931ba Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:54:16 +0300 Subject: [PATCH 17/22] wip Signed-off-by: Simo --- components/drops/view/part/DropPartMarkdown.tsx | 2 +- components/waves/drops/EditDropLexical.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index f805ed91dd..e3be24cd84 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -87,7 +87,7 @@ const CodeBlockRenderer = ({ }, [className]); useEffect(() => { - if (typeof globalThis.window === "undefined") { + if (globalThis.window === undefined) { return; } diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 50c4c51451..7287b42cd8 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -81,7 +81,7 @@ const convertCodeNodesToFences = (root: RootNode) => { if ($isCodeNode(node)) { const language = node.getLanguage?.() ?? ""; - const safeLanguage = language.trim().replace(/[`\n\r]/g, ""); + const safeLanguage = language.trim().replaceAll(/[`\n\r]/g, ""); const codeText = node.getTextContent(); const normalizedCode = codeText.endsWith("\n") ? codeText From bdb15b54544dd2c1bf040944125a4cdfce20de60 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 13:58:16 +0300 Subject: [PATCH 18/22] wip Signed-off-by: Simo --- components/waves/drops/EditDropLexical.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 7287b42cd8..9369c3441d 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -86,7 +86,12 @@ const convertCodeNodesToFences = (root: RootNode) => { const normalizedCode = codeText.endsWith("\n") ? codeText : `${codeText}\n`; - const fence = "```"; + 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}`; From 054bed6e3529ef52d8b72719d14602383622733e Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 14:11:19 +0300 Subject: [PATCH 19/22] wip Signed-off-by: Simo --- components/drops/view/part/DropPartMarkdown.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index e3be24cd84..f8653a9928 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -1,4 +1,5 @@ import { + Children, memo, useEffect, useMemo, @@ -77,6 +78,18 @@ const CodeBlockRenderer = ({ }: 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" @@ -97,7 +110,7 @@ const CodeBlockRenderer = ({ } void highlightCodeElement(element, language); - }, [language, children]); + }, [language, codeText]); return ( Date: Wed, 1 Oct 2025 14:29:36 +0300 Subject: [PATCH 20/22] wip Signed-off-by: Simo --- .../CreateDropContent.component.test.tsx | 1 + .../waves/drops/EditDropLexical.test.tsx | 445 +++++++----------- 2 files changed, 176 insertions(+), 270 deletions(-) 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); }); }); From aa22f3a4badca079573cde806cae918f1e789603 Mon Sep 17 00:00:00 2001 From: Simo Date: Wed, 1 Oct 2025 14:32:58 +0300 Subject: [PATCH 21/22] wip Signed-off-by: Simo --- components/drops/view/part/DropPartMarkdown.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index f8653a9928..add60e253e 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -109,8 +109,12 @@ const CodeBlockRenderer = ({ return; } + if (!codeText || codeText.trim() === "") { + return; + } + void highlightCodeElement(element, language); - }, [language, codeText]); + }); return ( Date: Wed, 1 Oct 2025 14:35:54 +0300 Subject: [PATCH 22/22] wip Signed-off-by: Simo --- components/waves/drops/EditDropLexical.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 9369c3441d..27ce1507dc 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -416,10 +416,10 @@ const EditDropLexical: React.FC = ({ /> + -