feat: rich text in headlines (#6685)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-10-16 15:59:46 +05:30
committed by GitHub
parent df191de1b4
commit 36c5fc4a65
100 changed files with 1523 additions and 1002 deletions
@@ -2,6 +2,7 @@ import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { ListItemNode, ListNode } from "@lexical/list";
import { TRANSFORMERS } from "@lexical/markdown";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
@@ -54,6 +55,8 @@ export type TextEditorProps = {
selectedLanguageCode?: string;
fallbacks?: { [id: string]: string };
addFallback?: () => void;
autoFocus?: boolean;
id?: string;
};
const editorConfig = {
@@ -118,10 +121,15 @@ export const Editor = (props: TextEditorProps) => {
style={{ height: props.height }}>
<RichTextPlugin
contentEditable={
<ContentEditable style={{ height: props.height }} className="editor-input" />
<ContentEditable
style={{ height: props.height }}
className="editor-input"
aria-labelledby={props.id}
dir="auto"
/>
}
placeholder={
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400" dir="auto">
{props.placeholder ?? ""}
</div>
}
@@ -130,6 +138,7 @@ export const Editor = (props: TextEditorProps) => {
<ListPlugin />
<LinkPlugin />
<AutoLinkPlugin />
{props.autoFocus && <AutoFocusPlugin />}
{props.localSurvey && props.questionId && props.selectedLanguageCode && (
<RecallPlugin
localSurvey={props.localSurvey}
@@ -3,7 +3,7 @@ import { cleanup, render } from "@testing-library/react";
import { $applyNodeReplacement } from "lexical";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import { $createRecallNode, RecallNode, RecallPayload, SerializedRecallNode } from "./recall-node";
vi.mock("lexical", () => ({
@@ -23,6 +23,19 @@ vi.mock("@/lib/utils/recall", () => ({
replaceRecallInfoWithUnderline: vi.fn((label: string) => {
return label.replace(/#recall:[^#]+#/g, "___");
}),
getTextContentWithRecallTruncated: vi.fn((text: string, maxLength: number = 25) => {
// Mock: strip HTML tags, clean whitespace, truncate, replace recall patterns
const cleanText = text.replace(/<|>/g, "").replace(/\s+/g, " ").trim();
const withRecallReplaced = cleanText.replace(/#recall:[^#]+#/g, "___");
if (withRecallReplaced.length <= maxLength) {
return withRecallReplaced;
}
const start = withRecallReplaced.slice(0, 10);
const end = withRecallReplaced.slice(-10);
return `${start}...${end}`;
}),
}));
describe("RecallNode", () => {
@@ -353,15 +366,15 @@ describe("RecallNode", () => {
expect(span?.textContent).toContain("@");
});
test("calls replaceRecallInfoWithUnderline with label", () => {
test("calls getTextContentWithRecallTruncated with label", () => {
const node = new RecallNode(mockPayload);
node.decorate();
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith("What is your name?");
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith("What is your name?");
});
test("handles label with nested recall patterns", () => {
vi.mocked(replaceRecallInfoWithUnderline).mockReturnValueOnce("Processed Label");
vi.mocked(getTextContentWithRecallTruncated).mockReturnValueOnce("Processed Label");
const payloadWithNestedRecall: RecallPayload = {
recallItem: {
@@ -376,7 +389,7 @@ describe("RecallNode", () => {
const decorated = node.decorate();
const { container } = render(<>{decorated}</>);
expect(vi.mocked(replaceRecallInfoWithUnderline)).toHaveBeenCalledWith(
expect(vi.mocked(getTextContentWithRecallTruncated)).toHaveBeenCalledWith(
"What is your #recall:name/fallback:name# answer?"
);
expect(container.textContent).toContain("@Processed Label");
@@ -4,7 +4,7 @@ import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, NodeKey, S
import { $applyNodeReplacement, DecoratorNode } from "lexical";
import { ReactNode } from "react";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
export interface RecallPayload {
recallItem: TSurveyRecallItem;
@@ -134,12 +134,13 @@ export class RecallNode extends DecoratorNode<ReactNode> {
}
decorate(): ReactNode {
const displayLabel = replaceRecallInfoWithUnderline(this.__recallItem.label);
const displayLabel = getTextContentWithRecallTruncated(this.__recallItem.label);
return (
<span
className="recall-node z-30 inline-flex h-fit justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-slate-700"
aria-label={`Recall: ${displayLabel}`}>
className="recall-node z-30 inline-flex h-fit justify-center whitespace-nowrap rounded-md bg-slate-100 text-sm text-slate-700"
aria-label={`Recall: ${displayLabel}`}
title={displayLabel}>
@{displayLabel}
</span>
);
@@ -223,7 +223,15 @@ export const RecallPlugin = ({
}
});
},
[localSurvey, selectedLanguageCode, editor, convertTextToRecallNodes, findAllRecallNodes]
[
findAllRecallNodes,
localSurvey,
selectedLanguageCode,
setRecallItems,
setFallbacks,
editor,
convertTextToRecallNodes,
]
);
// Handle @ key press for recall trigger
@@ -260,7 +268,7 @@ export const RecallPlugin = ({
}
return false;
},
[editor]
[editor, setShowRecallItemSelect]
);
// Close dropdown when clicking outside
@@ -277,7 +285,7 @@ export const RecallPlugin = ({
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [showRecallItemSelect]);
}, [setShowRecallItemSelect, showRecallItemSelect]);
// Clean up when dropdown closes
useEffect(() => {
@@ -385,11 +393,13 @@ export const RecallPlugin = ({
},
[
editor,
setShowRecallItemSelect,
recallItems,
setRecallItems,
atSymbolPosition,
replaceAtSymbolWithStoredPosition,
replaceAtSymbolWithCurrentSelection,
onShowFallbackInput,
recallItems,
]
);
@@ -20,9 +20,7 @@ import {
$getRoot,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
FORMAT_TEXT_COMMAND,
PASTE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from "lexical";
import { AtSign, Bold, ChevronDownIcon, Italic, Link, PencilIcon, Underline } from "lucide-react";
@@ -312,25 +310,8 @@ export const ToolbarPlugin = (
}
}, [editor, isLink, props]);
useEffect(() => {
return editor.registerCommand(
PASTE_COMMAND,
(e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text/plain");
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertRawText(text ?? "");
}
});
e.preventDefault();
return true; // Prevent the default paste handler
},
COMMAND_PRIORITY_CRITICAL
);
}, [editor]);
// Removed custom PASTE_COMMAND handler to allow Lexical's default paste handler
// to properly preserve rich text formatting (bold, italic, links, etc.)
if (!props.editable) return <></>;
@@ -423,18 +404,20 @@ export const ToolbarPlugin = (
</DropdownMenu>
)}
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
!props.excludedToolbarItems?.includes(key) ? (
<ToolbarButton
key={key}
icon={icon}
active={active}
disabled={disabled}
onClick={onClick}
tooltipText={tooltipText}
/>
) : null
)}
<div className="flex items-center gap-1">
{items.map(({ key, icon, onClick, active, tooltipText, disabled }) =>
!props.excludedToolbarItems?.includes(key) ? (
<ToolbarButton
key={key}
icon={icon}
active={active}
disabled={disabled}
onClick={onClick}
tooltipText={tooltipText}
/>
) : null
)}
</div>
</div>
);
};
@@ -21,7 +21,6 @@
position: relative;
line-height: 24px;
font-weight: 400;
text-align: left;
border-color: #cbd5e1;
border-width: 1px;
padding: 1px;
@@ -36,11 +35,11 @@
position: relative;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
overflow: auto;
overflow-y: auto;
resize: vertical;
height: auto;
min-height: var(--editor-min-height, 40px);
max-height: 150px;
min-height: var(--editor-min-height, 48px);
max-height: 200px;
}
.editor-input {
@@ -49,7 +48,7 @@
position: relative;
tab-size: 1;
outline: 0;
padding: 10px 10px;
padding: 5px 10px 10px 10px;
outline: none;
}
@@ -349,4 +348,4 @@ i.link {
.inactive-button {
color: #777;
}
}