diff --git a/packages/mentis/src/hooks/useContentEditableMention.ts b/packages/mentis/src/hooks/useContentEditableMention.ts index ebd0926..dd8d68d 100644 --- a/packages/mentis/src/hooks/useContentEditableMention.ts +++ b/packages/mentis/src/hooks/useContentEditableMention.ts @@ -120,7 +120,7 @@ export function useContentEditableMention({ return; } - // Handle selection + // Handle selection when modal is open with options if (showModal && filteredOptions.length > 0) { if (key === "Enter" || key === "Tab") { e.preventDefault(); diff --git a/packages/mentis/src/utils/extractMentionData.ts b/packages/mentis/src/utils/extractMentionData.ts index 5622384..657f2cc 100644 --- a/packages/mentis/src/utils/extractMentionData.ts +++ b/packages/mentis/src/utils/extractMentionData.ts @@ -15,6 +15,7 @@ export const extractMentionData = (element: HTMLElement): MentionData => { currentIndex += textContent.length; } else if (node.nodeType === Node.ELEMENT_NODE) { const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); // Check if this is a mention chip if (element.dataset.value && element.dataset.label) { @@ -33,11 +34,39 @@ export const extractMentionData = (element: HTMLElement): MentionData => { // dataValue shows the value (actual data) dataValue += element.dataset.value; currentIndex += chipText.length; + } else if (tagName === "br") { + // Handle
elements as newlines + displayValue += "\n"; + dataValue += "\n"; + currentIndex += 1; + } else if (tagName === "div" && element.childNodes.length === 0) { + // Handle empty
elements as newlines (contentEditable creates these for Enter) + displayValue += "\n"; + dataValue += "\n"; + currentIndex += 1; } else { + // Handle
with content and other block elements that should add newlines + const isBlockElement = ["div", "p", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName); + const hasContent = element.childNodes.length > 0; + + // Add newline before block element (except for the first element) + if (isBlockElement && hasContent && (displayValue.length > 0 || dataValue.length > 0)) { + displayValue += "\n"; + dataValue += "\n"; + currentIndex += 1; + } + // Recursively process child nodes for (const child of Array.from(element.childNodes)) { walkNodes(child); } + + // Add newline after block element (except for the last element in the container) + if (isBlockElement && hasContent && element.nextSibling) { + displayValue += "\n"; + dataValue += "\n"; + currentIndex += 1; + } } } }; diff --git a/packages/mentis/src/utils/reconstructFromDataValue.ts b/packages/mentis/src/utils/reconstructFromDataValue.ts index 9bb8faf..4398aa6 100644 --- a/packages/mentis/src/utils/reconstructFromDataValue.ts +++ b/packages/mentis/src/utils/reconstructFromDataValue.ts @@ -77,11 +77,17 @@ export const reconstructFromDataValue = ({ } } + // Helper function to convert newlines to HTML + const convertNewlinesToHTML = (text: string): string => { + return text.replace(/\n/g, '
'); + }; + // Build the HTML content for (const match of filteredMatches) { // Add any text before this mention if (match.index > currentIndex) { - result += dataValue.slice(currentIndex, match.index); + const textBeforeMention = dataValue.slice(currentIndex, match.index); + result += convertNewlinesToHTML(textBeforeMention); } // Add the mention chip @@ -91,7 +97,7 @@ export const reconstructFromDataValue = ({ result += `${chipContent}`; } else { // If we can't find the option, just add the value as plain text - result += match.value; + result += convertNewlinesToHTML(match.value); } currentIndex = match.index + match.length; @@ -99,7 +105,8 @@ export const reconstructFromDataValue = ({ // Add any remaining text after the last mention if (currentIndex < dataValue.length) { - result += dataValue.slice(currentIndex); + const remainingText = dataValue.slice(currentIndex); + result += convertNewlinesToHTML(remainingText); } return result; diff --git a/packages/mentis/tests/newlines.test.tsx b/packages/mentis/tests/newlines.test.tsx new file mode 100644 index 0000000..360d3f7 --- /dev/null +++ b/packages/mentis/tests/newlines.test.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { expect, test, vi, describe } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { MentionInput } from "../src/components/MentionInput"; +import { extractMentionData } from "../src/utils/extractMentionData"; +import { reconstructFromDataValue } from "../src/utils/reconstructFromDataValue"; + +const options = [ + { label: "John Doe", value: "john" }, + { label: "Jane Smith", value: "jane" }, +]; + +describe("Newline handling", () => { + test("Should preserve newlines in dataValue and displayValue when pressing Enter", async () => { + const mockOnChange = vi.fn(); + render(); + + const user = userEvent.setup(); + const editorElement = screen.getByRole("combobox"); + + // Type some text, then press Enter, then type more text + await user.type(editorElement, "First line{enter}Second line"); + + // Check that onChange was called with newlines preserved + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + displayValue: "First line\nSecond line", + dataValue: "First line\nSecond line", + mentions: [], + }) + ); + }); + + test("Should extract newlines correctly from contentEditable with
elements", () => { + // Create a test element with
tags + const testElement = document.createElement("div"); + testElement.innerHTML = "First line
Second line
Third line"; + + const result = extractMentionData(testElement); + + expect(result.displayValue).toBe("First line\nSecond line\nThird line"); + expect(result.dataValue).toBe("First line\nSecond line\nThird line"); + expect(result.mentions).toEqual([]); + }); + + test("Should extract newlines correctly from contentEditable with empty
elements", () => { + // Create a test element with empty div tags (contentEditable creates these for Enter) + const testElement = document.createElement("div"); + testElement.innerHTML = "First line
Second line"; + + const result = extractMentionData(testElement); + + expect(result.displayValue).toBe("First line\nSecond line"); + expect(result.dataValue).toBe("First line\nSecond line"); + expect(result.mentions).toEqual([]); + }); + + test("Should reconstruct HTML correctly with newlines", () => { + const dataValue = "First line\nSecond line\nThird line"; + + const result = reconstructFromDataValue({ + dataValue, + options, + trigger: "@", + keepTriggerOnSelect: true, + chipClassName: "mention-chip", + }); + + expect(result).toBe("First line
Second line
Third line"); + }); + + test("Should handle newlines with mentions correctly", () => { + // Create a test element with mentions and newlines + const testElement = document.createElement("div"); + testElement.innerHTML = 'Hello @John Doe
How are you?'; + + const result = extractMentionData(testElement); + + expect(result.displayValue).toBe("Hello @John Doe\nHow are you?"); + expect(result.dataValue).toBe("Hello john\nHow are you?"); + expect(result.mentions).toEqual([ + { + label: "John Doe", + value: "john", + startIndex: 6, + endIndex: 15, + }, + ]); + }); + + test("Should reconstruct HTML correctly with mentions and newlines", () => { + const dataValue = "Hello john\nHow are you?"; + + const result = reconstructFromDataValue({ + dataValue, + options, + trigger: "@", + keepTriggerOnSelect: true, + chipClassName: "mention-chip", + }); + + expect(result).toBe('Hello @John Doe
How are you?'); + }); + + test("Should handle multiple newlines correctly", () => { + // Create a test element with multiple newlines + const testElement = document.createElement("div"); + testElement.innerHTML = "Line 1

Line 3


Line 6"; + + const result = extractMentionData(testElement); + + expect(result.displayValue).toBe("Line 1\n\nLine 3\n\n\nLine 6"); + expect(result.dataValue).toBe("Line 1\n\nLine 3\n\n\nLine 6"); + expect(result.mentions).toEqual([]); + }); + + test("Should reconstruct multiple newlines correctly", () => { + const dataValue = "Line 1\n\nLine 3\n\n\nLine 6"; + + const result = reconstructFromDataValue({ + dataValue, + options, + trigger: "@", + keepTriggerOnSelect: true, + chipClassName: "mention-chip", + }); + + expect(result).toBe("Line 1

Line 3


Line 6"); + }); +});