From 39aecab564456ee47725afa5831bae4197b0fbbc Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 9 Oct 2025 16:59:42 +0200 Subject: [PATCH 1/5] wip Signed-off-by: Simo --- .../components/CreateDropWrapper.test.tsx | 7 +- .../CreateDropContent.component.test.tsx | 6 ++ .../create/utils/CreateDropWrapper.test.tsx | 12 ++- .../waves/drops/EditDropLexical.test.tsx | 28 ++++-- .../waves/drops/normalizeDropMarkdown.test.ts | 56 +++++++++++ .../drops/create/utils/CreateDropContent.tsx | 25 ++--- .../drops/create/utils/CreateDropWrapper.tsx | 20 ++-- .../view/part/dropPartMarkdown/content.tsx | 10 +- components/waves/CreateDropContent.tsx | 20 ++-- components/waves/drops/EditDropLexical.tsx | 90 +++++++++++++----- .../waves/drops/blankLinePlaceholders.ts | 30 ++++++ .../waves/drops/normalizeDropMarkdown.ts | 94 +++++++++++++++++++ 12 files changed, 326 insertions(+), 72 deletions(-) create mode 100644 __tests__/components/waves/drops/normalizeDropMarkdown.test.ts create mode 100644 components/waves/drops/blankLinePlaceholders.ts create mode 100644 components/waves/drops/normalizeDropMarkdown.ts diff --git a/__tests__/components/CreateDropWrapper.test.tsx b/__tests__/components/CreateDropWrapper.test.tsx index 2ed081f012..0411e88b6c 100644 --- a/__tests__/components/CreateDropWrapper.test.tsx +++ b/__tests__/components/CreateDropWrapper.test.tsx @@ -19,7 +19,12 @@ jest.mock('@/components/drops/create/full/CreateDropFull', () => )) ); -jest.mock('@lexical/markdown', () => ({ $convertToMarkdownString: () => 'text', TRANSFORMERS: [] })); +jest.mock('@/components/waves/drops/normalizeDropMarkdown', () => ({ + __esModule: true, + default: (value: string) => value, + normalizeDropMarkdown: (value: string) => value, + exportDropMarkdown: () => 'text', +})); jest.mock('@/components/drops/create/lexical/transformers/MentionTransformer', () => ({})); jest.mock('@/components/drops/create/lexical/transformers/HastagTransformer', () => ({})); jest.mock('@/components/drops/create/lexical/transformers/ImageTransformer', () => ({})); diff --git a/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx b/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx index 5b1ff5ecf8..98c55a1e24 100644 --- a/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx +++ b/__tests__/components/drops/create/utils/CreateDropContent.component.test.tsx @@ -28,6 +28,12 @@ jest.mock('@/components/drops/create/utils/storm/CreateDropParts', () => () => < jest.mock('@/components/drops/create/utils/CreateDropActionsRow', () => () =>
); jest.mock('@/components/drops/create/utils/storm/CreateDropContentMissingMediaWarning', () => () =>
); jest.mock('@/components/drops/create/utils/storm/CreateDropContentMissingMetadataWarning', () => () =>
); +jest.mock('@/components/waves/drops/normalizeDropMarkdown', () => ({ + __esModule: true, + default: (value: string) => value, + normalizeDropMarkdown: (value: string) => value, + exportDropMarkdown: () => '', +})); let linkProps: any = null; let mockClear: any; diff --git a/__tests__/components/drops/create/utils/CreateDropWrapper.test.tsx b/__tests__/components/drops/create/utils/CreateDropWrapper.test.tsx index ee21f63f43..88ac591153 100644 --- a/__tests__/components/drops/create/utils/CreateDropWrapper.test.tsx +++ b/__tests__/components/drops/create/utils/CreateDropWrapper.test.tsx @@ -16,10 +16,12 @@ jest.mock('react-use', () => ({ createBreakpoint: () => () => 'LG' })); -// Mock lexical -jest.mock('@lexical/markdown', () => ({ - $convertToMarkdownString: () => '', - TRANSFORMERS: [] +// Mock markdown utilities +jest.mock('@/components/waves/drops/normalizeDropMarkdown', () => ({ + __esModule: true, + default: (value: string) => value, + normalizeDropMarkdown: (value: string) => value, + exportDropMarkdown: () => '', })); // Mock transformers @@ -265,4 +267,4 @@ describe('CreateDropWrapper Authentication Validation', () => { expect(result.signer_address).toBe('0x1234567890123456789012345678901234567890'); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/components/waves/drops/EditDropLexical.test.tsx b/__tests__/components/waves/drops/EditDropLexical.test.tsx index c1a9fc941a..a349749a10 100644 --- a/__tests__/components/waves/drops/EditDropLexical.test.tsx +++ b/__tests__/components/waves/drops/EditDropLexical.test.tsx @@ -155,14 +155,25 @@ jest.mock('@lexical/react/LexicalComposerContext', () => ({ jest.mock('@lexical/markdown', () => ({ __esModule: true, $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('@/components/waves/drops/normalizeDropMarkdown', () => ({ + __esModule: true, + default: jest.fn((value: string) => value), + normalizeDropMarkdown: jest.fn((value: string) => value), + exportDropMarkdown: jest.fn(() => 'mock markdown'), +})); +const { + normalizeDropMarkdown: normalizeDropMarkdownMock, + exportDropMarkdown: exportDropMarkdownMock, +} = jest.requireMock('@/components/waves/drops/normalizeDropMarkdown') as { + normalizeDropMarkdown: jest.Mock; + exportDropMarkdown: jest.Mock; }; jest.mock('lexical', () => ({ $getRoot: getRootMock, @@ -197,7 +208,8 @@ describe('EditDropLexical', () => { beforeEach(() => { jest.clearAllMocks(); - convertToMarkdownStringMock.mockReturnValue('mock markdown'); + exportDropMarkdownMock.mockReturnValue('mock markdown'); + normalizeDropMarkdownMock.mockImplementation((value: string) => value); convertFromMarkdownStringMock.mockReset(); rootMock.getChildren.mockReturnValue([]); rootMock.getAllTextNodes.mockReturnValue([]); @@ -214,7 +226,7 @@ describe('EditDropLexical', () => { const user = userEvent.setup(); const onSave = jest.fn(); const onCancel = jest.fn(); - convertToMarkdownStringMock.mockReturnValue('updated markdown'); + exportDropMarkdownMock.mockReturnValue('updated markdown'); render(); @@ -239,7 +251,7 @@ describe('EditDropLexical', () => { const user = userEvent.setup(); const onSave = jest.fn(); const onCancel = jest.fn(); - convertToMarkdownStringMock.mockReturnValue('Initial content here'); + exportDropMarkdownMock.mockReturnValue('Initial content here'); render(); @@ -253,7 +265,7 @@ describe('EditDropLexical', () => { it('invokes keyboard command handlers', async () => { const onSave = jest.fn(); const onCancel = jest.fn(); - convertToMarkdownStringMock.mockReturnValue('changed content'); + exportDropMarkdownMock.mockReturnValue('changed content'); render(); @@ -282,7 +294,7 @@ describe('EditDropLexical', () => { handled = enterHandler?.({ shiftKey: false }) ?? false; }); expect(handled).toBe(true); - expect(convertToMarkdownStringMock).toHaveBeenCalled(); + expect(exportDropMarkdownMock).toHaveBeenCalled(); expect(onCancel).toHaveBeenCalledTimes(1); }); }); diff --git a/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts b/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts new file mode 100644 index 0000000000..dce9d5db0e --- /dev/null +++ b/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts @@ -0,0 +1,56 @@ +jest.mock("@lexical/markdown", () => ({ + __esModule: true, + $convertToMarkdownString: jest.fn(), +})); + +import { + exportDropMarkdown, + normalizeDropMarkdown, +} from "@/components/waves/drops/normalizeDropMarkdown"; +import type { EditorState } from "lexical"; + +const { + $convertToMarkdownString: convertToMarkdownStringMock, +} = jest.requireMock("@lexical/markdown") as { + $convertToMarkdownString: jest.Mock; +}; + +const createEditorStateStub = (): EditorState => + ({ + read: (fn: () => string) => fn(), + } as unknown as EditorState); + +describe("exportDropMarkdown", () => { + let editorState: EditorState; + + beforeEach(() => { + editorState = createEditorStateStub(); + convertToMarkdownStringMock.mockReset(); + }); + + it("returns markdown unchanged when no blank markers are present", () => { + convertToMarkdownStringMock.mockReturnValue("First\n\nSecond"); + expect(exportDropMarkdown(editorState, [])).toBe("First\n\nSecond"); + }); + + it("collapses a single blank paragraph marker into one additional newline", () => { + convertToMarkdownStringMock.mockReturnValue( + "First\n\n__BLANK_PARAGRAPH__\n\nSecond" + ); + expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\nSecond"); + }); + + it("collapses multiple blank markers into the correct newline count", () => { + convertToMarkdownStringMock.mockReturnValue( + "First\n\n__BLANK_PARAGRAPH__\n\n__BLANK_PARAGRAPH__\n\nSecond" + ); + expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n\nSecond"); + }); + +}); + +describe("normalizeDropMarkdown", () => { + it("normalizes CRLF to LF", () => { + expect(normalizeDropMarkdown("line1\r\nline2")).toBe("line1\nline2"); + }); +}); diff --git a/components/drops/create/utils/CreateDropContent.tsx b/components/drops/create/utils/CreateDropContent.tsx index 4185714cf7..0095a9d011 100644 --- a/components/drops/create/utils/CreateDropContent.tsx +++ b/components/drops/create/utils/CreateDropContent.tsx @@ -27,7 +27,6 @@ import { import { MaxLengthPlugin } from "../lexical/plugins/MaxLengthPlugin"; import ToggleViewButtonPlugin from "../lexical/plugins/ToggleViewButtonPlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; -import { $convertToMarkdownString } from "@lexical/markdown"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { ListNode, ListItemNode } from "@lexical/list"; @@ -70,6 +69,9 @@ import { EmojiNode } from "../lexical/nodes/EmojiNode"; import CreateDropEmojiPicker from "@/components/waves/CreateDropEmojiPicker"; import EmojiPlugin from "../lexical/plugins/emoji/EmojiPlugin"; import PlainTextPastePlugin from "../lexical/plugins/PlainTextPastePlugin"; +import { + exportDropMarkdown, +} from "@/components/waves/drops/normalizeDropMarkdown"; export interface CreateDropContentHandles { clearEditorState: () => void; @@ -193,16 +195,17 @@ const CreateDropContent = forwardRef< const currentPartCount = (drop?.parts.length ?? 0) + 1; const [charsCount, setCharsCount] = useState(0); useEffect(() => { - editorState?.read(() => - setCharsCount( - $convertToMarkdownString([ - ...SAFE_MARKDOWN_TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - IMAGE_TRANSFORMER, - ])?.length ?? 0 - ) - ); + if (!editorState) { + setCharsCount(0); + return; + } + const markdown = exportDropMarkdown(editorState, [ + ...SAFE_MARKDOWN_TRANSFORMERS, + MENTION_TRANSFORMER, + HASHTAG_TRANSFORMER, + IMAGE_TRANSFORMER, + ]); + setCharsCount(markdown.length); }, [editorState]); const [isStormMode, setIsStormMode] = useState(false); diff --git a/components/drops/create/utils/CreateDropWrapper.tsx b/components/drops/create/utils/CreateDropWrapper.tsx index e14c2850ce..dae50e5652 100644 --- a/components/drops/create/utils/CreateDropWrapper.tsx +++ b/components/drops/create/utils/CreateDropWrapper.tsx @@ -21,7 +21,6 @@ import { ReferencedNft, } from "@/entities/IDrop"; import { createBreakpoint } from "react-use"; -import { $convertToMarkdownString } from "@lexical/markdown"; import { CreateDropType, CreateDropViewType } from "../types"; import { MENTION_TRANSFORMER } from "../lexical/transformers/MentionTransformer"; import { HASHTAG_TRANSFORMER } from "../lexical/transformers/HastagTransformer"; @@ -38,6 +37,9 @@ import { SAFE_MARKDOWN_TRANSFORMERS } from "../lexical/transformers/markdownTran import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { WalletValidationError } from "@/src/errors/wallet"; +import { + exportDropMarkdown, +} from "@/components/waves/drops/normalizeDropMarkdown"; export enum CreateDropScreenType { DESKTOP = "DESKTOP", @@ -194,14 +196,14 @@ const CreateDropWrapper = forwardRef< ]); }; const getMarkdown = () => - editorState?.read(() => - $convertToMarkdownString([ - ...SAFE_MARKDOWN_TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - IMAGE_TRANSFORMER, - ]) - ) ?? null; + editorState + ? exportDropMarkdown(editorState, [ + ...SAFE_MARKDOWN_TRANSFORMERS, + MENTION_TRANSFORMER, + HASHTAG_TRANSFORMER, + IMAGE_TRANSFORMER, + ]) + : null; const getMissingRequiredMetadata = (): ApiWaveRequiredMetadata[] => { if (!waveProps?.id) { diff --git a/components/drops/view/part/dropPartMarkdown/content.tsx b/components/drops/view/part/dropPartMarkdown/content.tsx index 94c4b8ebfb..bb5f0c77e2 100644 --- a/components/drops/view/part/dropPartMarkdown/content.tsx +++ b/components/drops/view/part/dropPartMarkdown/content.tsx @@ -245,10 +245,12 @@ export const createMarkdownContentRenderers = ({ return content; } - return content.replace(/\n{4,}/g, (match: string) => { - const numParagraphs = Math.floor(match.length / 2) - 1; - const emptyParagraphs = Array(numParagraphs).fill("\n\n ").join(""); - return "\n\n" + emptyParagraphs + "\n\n"; + return content.replace(/\n{3,}/g, (match: string) => { + const extraBlankLines = match.length - 2; + const fillerParagraphs = Array(extraBlankLines) + .fill(" ") + .join("\n\n"); + return `\n\n${fillerParagraphs}\n\n`; }); }; diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx index d8ec2816c1..7f9ad60e0b 100644 --- a/components/waves/CreateDropContent.tsx +++ b/components/waves/CreateDropContent.tsx @@ -23,7 +23,6 @@ import { MentionedUser, ReferencedNft, } from "@/entities/IDrop"; -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"; @@ -59,6 +58,7 @@ import { getMissingRequirements, MissingRequirements, } from "./utils/getMissingRequirements"; +import { exportDropMarkdown } from "@/components/waves/drops/normalizeDropMarkdown"; import { EMOJI_TRANSFORMER } from "../drops/create/lexical/transformers/EmojiTransformer"; import { useDropSignature } from "@/hooks/drops/useDropSignature"; import { useWave } from "@/hooks/useWave"; @@ -488,15 +488,15 @@ const CreateDropContent: React.FC = ({ const getMarkdown = useMemo( () => - editorState?.read(() => - $convertToMarkdownString([ - ...SAFE_MARKDOWN_TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - IMAGE_TRANSFORMER, - EMOJI_TRANSFORMER, - ]) - ) ?? null, + editorState + ? exportDropMarkdown(editorState, [ + ...SAFE_MARKDOWN_TRANSFORMERS, + MENTION_TRANSFORMER, + HASHTAG_TRANSFORMER, + IMAGE_TRANSFORMER, + EMOJI_TRANSFORMER, + ]) + : null, [editorState] ); diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index defbb0dd89..424fbbcd61 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -1,6 +1,6 @@ "use client" -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { InitialConfigType, LexicalComposer, @@ -11,7 +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 { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"; +import { $convertFromMarkdownString } from "@lexical/markdown"; import { $getRoot, EditorState, @@ -54,6 +54,11 @@ import EmojiPlugin from "@/components/drops/create/lexical/plugins/emoji/EmojiPl import { EmojiNode } from "@/components/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"; +import normalizeDropMarkdown, { exportDropMarkdown } from "./normalizeDropMarkdown"; +import { + addBlankLinePlaceholders, + removeBlankLinePlaceholders, +} from "./blankLinePlaceholders"; interface EditDropLexicalProps { readonly initialContent: string; @@ -181,7 +186,11 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { useEffect(() => { editor.update(() => { - $convertFromMarkdownString(initialContent, EDIT_MARKDOWN_TRANSFORMERS); + const normalizedContent = normalizeDropMarkdown(initialContent); + $convertFromMarkdownString( + normalizedContent, + EDIT_MARKDOWN_TRANSFORMERS + ); const root = $getRoot(); convertCodeNodesToFences(root); @@ -266,6 +275,8 @@ function KeyboardPlugin({ mentionsRef: React.RefObject; }) { const [editor] = useLexicalComposerContext(); + const sanitizedInitialContent = + removeBlankLinePlaceholders(initialContent); useEffect(() => { const removeEscapeListener = editor.registerCommand( @@ -289,15 +300,20 @@ function KeyboardPlugin({ } if (!isSaving) { - editor.getEditorState().read(() => { - const currentMarkdown = - $convertToMarkdownString(EDIT_MARKDOWN_TRANSFORMERS); - if (currentMarkdown.trim() === initialContent.trim()) { - onCancel(); - } else { - onSave(); - } - }); + const currentMarkdown = exportDropMarkdown( + editor.getEditorState(), + EDIT_MARKDOWN_TRANSFORMERS + ); + const sanitizedCurrentMarkdown = + removeBlankLinePlaceholders(currentMarkdown); + if ( + sanitizedCurrentMarkdown.trim() === + sanitizedInitialContent.trim() + ) { + onCancel(); + } else { + onSave(); + } } return true; }, @@ -308,7 +324,15 @@ function KeyboardPlugin({ removeEscapeListener(); removeEnterListener(); }; - }, [editor, onSave, onCancel, isSaving, initialContent, mentionsRef]); + }, [ + editor, + onSave, + onCancel, + isSaving, + initialContent, + mentionsRef, + sanitizedInitialContent, + ]); return null; } @@ -327,6 +351,14 @@ const EditDropLexical: React.FC = ({ const editorRef = useRef(null); const mentionsRef = useRef(null); const { isApp } = useDeviceInfo(); + const normalizedInitialContent = useMemo( + () => normalizeDropMarkdown(initialContent), + [initialContent] + ); + const editorInitialContent = useMemo( + () => addBlankLinePlaceholders(normalizedInitialContent), + [normalizedInitialContent] + ); const initialConfig: InitialConfigType = { namespace: "EditDropLexical", @@ -378,17 +410,27 @@ const EditDropLexical: React.FC = ({ const handleSave = useCallback(() => { if (!editorState) return; - editorState.read(() => { - const markdown = $convertToMarkdownString(EDIT_MARKDOWN_TRANSFORMERS); + const markdown = exportDropMarkdown( + editorState, + EDIT_MARKDOWN_TRANSFORMERS + ); - if (markdown.trim() === initialContent.trim()) { - onCancel(); - return; - } + const sanitizedMarkdown = + removeBlankLinePlaceholders(markdown); - onSave(markdown, mentionedUsers); - }); - }, [editorState, mentionedUsers, onSave, initialContent, onCancel]); + if (sanitizedMarkdown.trim() === normalizedInitialContent.trim()) { + onCancel(); + return; + } + + onSave(sanitizedMarkdown, mentionedUsers); + }, [ + editorState, + mentionedUsers, + onSave, + normalizedInitialContent, + onCancel, + ]); return (
@@ -428,13 +470,13 @@ const EditDropLexical: React.FC = ({ onSelect={handleMentionSelect} /> - +
diff --git a/components/waves/drops/blankLinePlaceholders.ts b/components/waves/drops/blankLinePlaceholders.ts new file mode 100644 index 0000000000..243a27452d --- /dev/null +++ b/components/waves/drops/blankLinePlaceholders.ts @@ -0,0 +1,30 @@ +// Zero-width space used to keep otherwise empty paragraphs visible in Lexical. +const BLANK_LINE_PLACEHOLDER = "\u200B"; + +export const addBlankLinePlaceholders = (markdown: string): string => { + if (!markdown) { + return markdown; + } + + return markdown.replace(/\n{3,}/g, (match) => { + const extraNewLines = match.length - 2; + const placeholderSegment = (`${BLANK_LINE_PLACEHOLDER}\n`).repeat( + extraNewLines + ); + + return `\n\n${placeholderSegment}`; + }); +}; + +export const removeBlankLinePlaceholders = (markdown: string): string => { + if (!markdown) { + return markdown; + } + + return markdown.replaceAll(BLANK_LINE_PLACEHOLDER, ""); +}; + +export default { + addBlankLinePlaceholders, + removeBlankLinePlaceholders, +}; diff --git a/components/waves/drops/normalizeDropMarkdown.ts b/components/waves/drops/normalizeDropMarkdown.ts new file mode 100644 index 0000000000..f8d6283ff3 --- /dev/null +++ b/components/waves/drops/normalizeDropMarkdown.ts @@ -0,0 +1,94 @@ +import { + $convertToMarkdownString, + type Transformer, +} from "@lexical/markdown"; +import { + $isLineBreakNode, + $isParagraphNode, + $isTextNode, + type EditorState, + type LexicalNode, +} from "lexical"; + +const ZERO_WIDTH_SPACE_REGEX = /\u200B/g; +const BLANK_PARAGRAPH_MARKER = "__BLANK_PARAGRAPH__"; +const BLANK_RUN_REGEX = new RegExp( + `(?:${BLANK_PARAGRAPH_MARKER}\\n\\n)+`, + "g" +); +const BLANK_PARAGRAPH_TOKEN_REGEX = new RegExp( + `${BLANK_PARAGRAPH_MARKER}\\n?`, + "g" +); + +const isBlankParagraph = (node: LexicalNode): boolean => { + if (!$isParagraphNode(node)) { + return false; + } + + const children = node.getChildren(); + if (children.length === 0) { + return true; + } + + return children.every((child) => { + if ($isLineBreakNode(child)) { + return true; + } + + if ($isTextNode(child)) { + const text = child + .getTextContent() + .replace(ZERO_WIDTH_SPACE_REGEX, "") + .trim(); + return text.length === 0; + } + + return false; + }); +}; + +const blankParagraphTransformer: Transformer = { + type: "element", + dependencies: [], + export: (node) => (isBlankParagraph(node) ? BLANK_PARAGRAPH_MARKER : null), + regExp: /(?:)/, + replace: () => {}, +}; + +const normalizeLineEndings = (markdown: string): string => + markdown.replace(/\r\n/g, "\n"); + +const collapseBlankParagraphMarkers = (markdown: string): string => { + if (!markdown) { + return markdown; + } + + const collapsedRuns = markdown.replace(BLANK_RUN_REGEX, (match) => { + const markerCount = match.split(BLANK_PARAGRAPH_MARKER).length - 1; + return "\n".repeat(markerCount); + }); + + return collapsedRuns.replace(BLANK_PARAGRAPH_TOKEN_REGEX, ""); +}; + +export const exportDropMarkdown = ( + editorState: EditorState, + transformers: Transformer[] +): string => { + const rawMarkdown = editorState.read(() => + $convertToMarkdownString([blankParagraphTransformer, ...transformers]) + ); + + return normalizeLineEndings(collapseBlankParagraphMarkers(rawMarkdown)); +}; + +export const normalizeDropMarkdown = (markdown: string): string => { + if (!markdown) { + return markdown; + } + + return normalizeLineEndings(markdown); +}; + +export default normalizeDropMarkdown; From 51851b09bc6e07b8807232cff90da18a24276ef7 Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 9 Oct 2025 17:22:11 +0200 Subject: [PATCH 2/5] wip Signed-off-by: Simo --- .../waves/drops/normalizeDropMarkdown.test.ts | 12 ++++++++++++ components/waves/drops/normalizeDropMarkdown.ts | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts b/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts index dce9d5db0e..ef897cd56d 100644 --- a/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts +++ b/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts @@ -47,6 +47,18 @@ describe("exportDropMarkdown", () => { expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n\nSecond"); }); + it("preserves trailing blank paragraphs", () => { + convertToMarkdownStringMock.mockReturnValue("First\n\n__BLANK_PARAGRAPH__"); + expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n"); + }); + + it("preserves multiple trailing blank paragraphs", () => { + convertToMarkdownStringMock.mockReturnValue( + "First\n\n__BLANK_PARAGRAPH__\n\n__BLANK_PARAGRAPH__" + ); + expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n\n"); + }); + }); describe("normalizeDropMarkdown", () => { diff --git a/components/waves/drops/normalizeDropMarkdown.ts b/components/waves/drops/normalizeDropMarkdown.ts index f8d6283ff3..8594ae6c3f 100644 --- a/components/waves/drops/normalizeDropMarkdown.ts +++ b/components/waves/drops/normalizeDropMarkdown.ts @@ -17,7 +17,7 @@ const BLANK_RUN_REGEX = new RegExp( "g" ); const BLANK_PARAGRAPH_TOKEN_REGEX = new RegExp( - `${BLANK_PARAGRAPH_MARKER}\\n?`, + `${BLANK_PARAGRAPH_MARKER}(\\n?)`, "g" ); @@ -69,7 +69,10 @@ const collapseBlankParagraphMarkers = (markdown: string): string => { return "\n".repeat(markerCount); }); - return collapsedRuns.replace(BLANK_PARAGRAPH_TOKEN_REGEX, ""); + return collapsedRuns.replace( + BLANK_PARAGRAPH_TOKEN_REGEX, + (_match, trailingNewline: string) => `\n${trailingNewline}` + ); }; export const exportDropMarkdown = ( From ded17a699bc3bf18120800029b1f5cc3a897ebb5 Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 9 Oct 2025 17:36:38 +0200 Subject: [PATCH 3/5] wip Signed-off-by: Simo --- components/waves/drops/normalizeDropMarkdown.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/waves/drops/normalizeDropMarkdown.ts b/components/waves/drops/normalizeDropMarkdown.ts index 8594ae6c3f..0662af08c7 100644 --- a/components/waves/drops/normalizeDropMarkdown.ts +++ b/components/waves/drops/normalizeDropMarkdown.ts @@ -12,12 +12,14 @@ import { const ZERO_WIDTH_SPACE_REGEX = /\u200B/g; const BLANK_PARAGRAPH_MARKER = "__BLANK_PARAGRAPH__"; +const SENTINEL_BOUNDARY = "\u2063"; +const BLANK_PARAGRAPH_SENTINEL = `${SENTINEL_BOUNDARY}${BLANK_PARAGRAPH_MARKER}${SENTINEL_BOUNDARY}`; const BLANK_RUN_REGEX = new RegExp( - `(?:${BLANK_PARAGRAPH_MARKER}\\n\\n)+`, + `(?:${BLANK_PARAGRAPH_SENTINEL}\\n\\n)+`, "g" ); const BLANK_PARAGRAPH_TOKEN_REGEX = new RegExp( - `${BLANK_PARAGRAPH_MARKER}(\\n?)`, + `${BLANK_PARAGRAPH_SENTINEL}(\\n?)`, "g" ); @@ -51,7 +53,7 @@ const isBlankParagraph = (node: LexicalNode): boolean => { const blankParagraphTransformer: Transformer = { type: "element", dependencies: [], - export: (node) => (isBlankParagraph(node) ? BLANK_PARAGRAPH_MARKER : null), + export: (node) => (isBlankParagraph(node) ? BLANK_PARAGRAPH_SENTINEL : null), regExp: /(?:)/, replace: () => {}, }; @@ -65,7 +67,7 @@ const collapseBlankParagraphMarkers = (markdown: string): string => { } const collapsedRuns = markdown.replace(BLANK_RUN_REGEX, (match) => { - const markerCount = match.split(BLANK_PARAGRAPH_MARKER).length - 1; + const markerCount = match.split(BLANK_PARAGRAPH_SENTINEL).length - 1; return "\n".repeat(markerCount); }); From 62657c2f15dc852a6a5d8baed93dc715dc3ed283 Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 10 Oct 2025 08:51:18 +0200 Subject: [PATCH 4/5] wip Signed-off-by: Simo --- components/waves/drops/blankLinePlaceholders.ts | 2 +- components/waves/drops/normalizeDropMarkdown.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/waves/drops/blankLinePlaceholders.ts b/components/waves/drops/blankLinePlaceholders.ts index 243a27452d..981ef26c25 100644 --- a/components/waves/drops/blankLinePlaceholders.ts +++ b/components/waves/drops/blankLinePlaceholders.ts @@ -6,7 +6,7 @@ export const addBlankLinePlaceholders = (markdown: string): string => { return markdown; } - return markdown.replace(/\n{3,}/g, (match) => { + return markdown.replaceAll(/\n{3,}/g, (match) => { const extraNewLines = match.length - 2; const placeholderSegment = (`${BLANK_LINE_PLACEHOLDER}\n`).repeat( extraNewLines diff --git a/components/waves/drops/normalizeDropMarkdown.ts b/components/waves/drops/normalizeDropMarkdown.ts index 0662af08c7..da70b06281 100644 --- a/components/waves/drops/normalizeDropMarkdown.ts +++ b/components/waves/drops/normalizeDropMarkdown.ts @@ -41,7 +41,7 @@ const isBlankParagraph = (node: LexicalNode): boolean => { if ($isTextNode(child)) { const text = child .getTextContent() - .replace(ZERO_WIDTH_SPACE_REGEX, "") + .replaceAll(ZERO_WIDTH_SPACE_REGEX, "") .trim(); return text.length === 0; } @@ -59,19 +59,19 @@ const blankParagraphTransformer: Transformer = { }; const normalizeLineEndings = (markdown: string): string => - markdown.replace(/\r\n/g, "\n"); + markdown.replaceAll(/\r\n/g, "\n"); const collapseBlankParagraphMarkers = (markdown: string): string => { if (!markdown) { return markdown; } - const collapsedRuns = markdown.replace(BLANK_RUN_REGEX, (match) => { + const collapsedRuns = markdown.replaceAll(BLANK_RUN_REGEX, (match) => { const markerCount = match.split(BLANK_PARAGRAPH_SENTINEL).length - 1; return "\n".repeat(markerCount); }); - return collapsedRuns.replace( + return collapsedRuns.replaceAll( BLANK_PARAGRAPH_TOKEN_REGEX, (_match, trailingNewline: string) => `\n${trailingNewline}` ); From 64fbbffed59dbf450c256fd1a92c900310df085e Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 10 Oct 2025 09:37:08 +0200 Subject: [PATCH 5/5] wip Signed-off-by: Simo --- .../waves/drops/normalizeDropMarkdown.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts b/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts index ef897cd56d..9f79272713 100644 --- a/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts +++ b/__tests__/components/waves/drops/normalizeDropMarkdown.test.ts @@ -9,6 +9,8 @@ import { } from "@/components/waves/drops/normalizeDropMarkdown"; import type { EditorState } from "lexical"; +const BLANK_PARAGRAPH_SENTINEL = "\u2063__BLANK_PARAGRAPH__\u2063"; + const { $convertToMarkdownString: convertToMarkdownStringMock, } = jest.requireMock("@lexical/markdown") as { @@ -35,26 +37,28 @@ describe("exportDropMarkdown", () => { it("collapses a single blank paragraph marker into one additional newline", () => { convertToMarkdownStringMock.mockReturnValue( - "First\n\n__BLANK_PARAGRAPH__\n\nSecond" + `First\n\n${BLANK_PARAGRAPH_SENTINEL}\n\nSecond` ); expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\nSecond"); }); it("collapses multiple blank markers into the correct newline count", () => { convertToMarkdownStringMock.mockReturnValue( - "First\n\n__BLANK_PARAGRAPH__\n\n__BLANK_PARAGRAPH__\n\nSecond" + `First\n\n${BLANK_PARAGRAPH_SENTINEL}\n\n${BLANK_PARAGRAPH_SENTINEL}\n\nSecond` ); expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n\nSecond"); }); it("preserves trailing blank paragraphs", () => { - convertToMarkdownStringMock.mockReturnValue("First\n\n__BLANK_PARAGRAPH__"); + convertToMarkdownStringMock.mockReturnValue( + `First\n\n${BLANK_PARAGRAPH_SENTINEL}` + ); expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n"); }); it("preserves multiple trailing blank paragraphs", () => { convertToMarkdownStringMock.mockReturnValue( - "First\n\n__BLANK_PARAGRAPH__\n\n__BLANK_PARAGRAPH__" + `First\n\n${BLANK_PARAGRAPH_SENTINEL}\n\n${BLANK_PARAGRAPH_SENTINEL}` ); expect(exportDropMarkdown(editorState, [])).toBe("First\n\n\n\n"); });