Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mentis/src/hooks/useContentEditableMention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions packages/mentis/src/utils/extractMentionData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 <br> elements as newlines
displayValue += "\n";
dataValue += "\n";
currentIndex += 1;
} else if (tagName === "div" && element.childNodes.length === 0) {
// Handle empty <div> elements as newlines (contentEditable creates these for Enter)
displayValue += "\n";
dataValue += "\n";
currentIndex += 1;
} else {
// Handle <div> 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;
}
}
}
};
Expand Down
13 changes: 10 additions & 3 deletions packages/mentis/src/utils/reconstructFromDataValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,17 @@ export const reconstructFromDataValue = ({
}
}

// Helper function to convert newlines to HTML
const convertNewlinesToHTML = (text: string): string => {
return text.replace(/\n/g, '<br>');
};

// 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
Expand All @@ -91,15 +97,16 @@ export const reconstructFromDataValue = ({
result += `<span class="${chipClassName}" data-value="${match.value}" data-label="${currentLabel}" contenteditable="false">${chipContent}</span>`;
} 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;
}

// 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;
Expand Down
131 changes: 131 additions & 0 deletions packages/mentis/tests/newlines.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MentionInput options={options} onChange={mockOnChange} />);

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 <br> elements", () => {
// Create a test element with <br> tags
const testElement = document.createElement("div");
testElement.innerHTML = "First line<br>Second line<br>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 <div> elements", () => {
// Create a test element with empty div tags (contentEditable creates these for Enter)
const testElement = document.createElement("div");
testElement.innerHTML = "First line<div></div>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<br>Second line<br>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 <span class="mention-chip" data-value="john" data-label="John Doe" contenteditable="false">@John Doe</span><br>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 <span class="mention-chip" data-value="john" data-label="John Doe" contenteditable="false">@John Doe</span><br>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<br><br>Line 3<br><br><br>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<br><br>Line 3<br><br><br>Line 6");
});
});