diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
index 988944d6cd..372bd47c3b 100644
--- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
+### Fixed
+
+- We fixed an issue where the "Default font family" and "Default font size" settings did not set the initial value of the font family and font size toolbar options.
+
## [5.1.0] - 2026-09-29
### Added
diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.defaultFont.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.defaultFont.spec.tsx
new file mode 100644
index 0000000000..a635f2d793
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.defaultFont.spec.tsx
@@ -0,0 +1,207 @@
+import "@testing-library/jest-dom";
+import { act, render } from "@testing-library/react";
+import { Editor } from "@tiptap/core";
+import { DynamicValue } from "mendix";
+import { dynamic, EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
+import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps";
+import RichText from "../RichText";
+
+const plainValue = `
Plain text
`;
+
+describe("Rich Text default font family and size", () => {
+ let defaultProps: RichTextContainerProps;
+
+ beforeEach(() => {
+ defaultProps = {
+ name: "RichText",
+ id: "RichText1",
+ stringAttribute: new EditableValueBuilder().withValue(plainValue).build(),
+ // The font family / size dropdowns live in the "fontColor" group (preset "standard" and up).
+ preset: "standard",
+ toolbarLocation: "top",
+ widthUnit: "percentage",
+ width: 100,
+ heightUnit: "percentageOfWidth",
+ height: 75,
+ toolbarConfig: "basic",
+ history: true,
+ fontStyle: true,
+ fontScript: true,
+ fontColor: true,
+ code: true,
+ indent: true,
+ embed: true,
+ align: true,
+ list: true,
+ remove: true,
+ header: true,
+ view: true,
+ tableBetter: false,
+ helpButton: true,
+ advancedConfig: [],
+ readOnlyStyle: "text",
+ tabIndex: 0,
+ onChangeType: "onLeave",
+ enableStatusBar: false,
+ dialogStyle: "inline",
+ statusBarContent: "wordCount" as StatusBarContentEnum,
+ spellCheck: true,
+ minHeightUnit: "none",
+ maxHeightUnit: "none",
+ maxHeight: 0,
+ minHeight: 75,
+ OverflowY: "auto",
+ customFonts: [],
+ enableDefaultUpload: true,
+ linkValidation: true,
+ styleDataFormat: "class"
+ };
+ });
+
+ function renderWidget(props: Partial = {}): ReturnType {
+ return render();
+ }
+
+ function fontFamilyLabel(container: HTMLElement): string | null | undefined {
+ return container.querySelector(".toolbar-dropdown-button.fontFamily .dropdown-label")?.textContent;
+ }
+
+ function fontSizeLabel(container: HTMLElement): string | null | undefined {
+ return container.querySelector(".toolbar-dropdown-button.fontSize .dropdown-label")?.textContent;
+ }
+
+ function getEditor(container: HTMLElement): Editor {
+ return (container.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }).editor;
+ }
+
+ describe("font family dropdown", () => {
+ it("shows the placeholder when no default is configured", () => {
+ const { container } = renderWidget();
+ expect(fontFamilyLabel(container)).toBe("Font Family");
+ });
+
+ it("shows the configured default when the selection has no font family", () => {
+ const { container } = renderWidget({ defaultFontFamily: dynamic.available("arial") });
+ expect(fontFamilyLabel(container)).toBe("Arial");
+ });
+
+ it("matches the configured default by font name", () => {
+ const { container } = renderWidget({ defaultFontFamily: dynamic.available("Times New Roman") });
+ expect(fontFamilyLabel(container)).toBe("Times New Roman");
+ });
+
+ it("matches a custom font configured as default", () => {
+ const { container } = renderWidget({
+ customFonts: [{ fontName: "Roboto", fontStyle: "roboto, sans-serif" }],
+ defaultFontFamily: dynamic.available("Roboto")
+ });
+ expect(fontFamilyLabel(container)).toBe("Roboto");
+ });
+
+ it("shows the font family of the selection when it has one", () => {
+ const { container } = renderWidget({
+ stringAttribute: new EditableValueBuilder()
+ .withValue(`Styled
`)
+ .build(),
+ styleDataFormat: "inline",
+ defaultFontFamily: dynamic.available("arial")
+ });
+ expect(fontFamilyLabel(container)).toBe("Impact");
+ });
+
+ it.each<[string, DynamicValue]>([
+ ["loading", dynamic.loading("arial")],
+ ["unavailable", dynamic.unavailable()],
+ ["empty", dynamic.available("")],
+ ["unknown", dynamic.available("no-such-font")],
+ ["unsafe", dynamic.available("arial; color: red")]
+ ])("shows the placeholder when the default is %s", (_, value) => {
+ const { container } = renderWidget({ defaultFontFamily: value });
+ expect(fontFamilyLabel(container)).toBe("Font Family");
+ });
+
+ it("follows changes of the configured default", () => {
+ const { container, rerender } = renderWidget({ defaultFontFamily: dynamic.available("arial") });
+ rerender();
+ expect(fontFamilyLabel(container)).toBe("Impact");
+ });
+ });
+
+ describe("font size dropdown", () => {
+ it("keeps the current fallback when no default is configured", () => {
+ const { container } = renderWidget();
+ expect(fontSizeLabel(container)).toBe("14px");
+ });
+
+ it("shows the configured default when the selection has no font size", () => {
+ const { container } = renderWidget({ defaultFontSize: dynamic.available("20px") });
+ expect(fontSizeLabel(container)).toBe("20px");
+ });
+
+ it("normalizes a bare number to px", () => {
+ const { container } = renderWidget({ defaultFontSize: dynamic.available("24") });
+ expect(fontSizeLabel(container)).toBe("24px");
+ });
+
+ it("shows the font size of the selection when it has one", () => {
+ const { container } = renderWidget({
+ stringAttribute: new EditableValueBuilder()
+ .withValue(`Styled
`)
+ .build(),
+ styleDataFormat: "inline",
+ defaultFontSize: dynamic.available("20px")
+ });
+ expect(fontSizeLabel(container)).toBe("32px");
+ });
+
+ it.each<[string, DynamicValue]>([
+ ["loading", dynamic.loading("20px")],
+ ["unavailable", dynamic.unavailable()],
+ ["empty", dynamic.available("")],
+ ["unsafe", dynamic.available("20px; color: red")]
+ ])("keeps the current fallback when the default is %s", (_, value) => {
+ const { container } = renderWidget({ defaultFontSize: value });
+ expect(fontSizeLabel(container)).toBe("14px");
+ });
+ });
+
+ describe("editor content", () => {
+ beforeEach(() => jest.useFakeTimers());
+ afterEach(() => jest.useRealTimers());
+
+ function renderAndFlush(props: Partial): {
+ container: HTMLElement;
+ stringAttribute: RichTextContainerProps["stringAttribute"];
+ } {
+ const stringAttribute = new EditableValueBuilder().withValue(plainValue).build();
+ const { container } = renderWidget({ stringAttribute, ...props });
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+ return { container, stringAttribute };
+ }
+
+ it("does not style the editor or change the saved value", () => {
+ const baseline = renderAndFlush({});
+ const baselineHtml = getEditor(baseline.container).getHTML();
+ baseline.container.remove();
+
+ const setAttribute = jest.spyOn(Element.prototype, "setAttribute");
+ const { container, stringAttribute } = renderAndFlush({
+ defaultFontFamily: dynamic.available("arial"),
+ defaultFontSize: dynamic.available("20px")
+ });
+
+ const dom = container.querySelector(".ProseMirror") as HTMLElement;
+ expect(dom.style.fontFamily).toBe("");
+ expect(dom.style.fontSize).toBe("");
+ expect(setAttribute.mock.calls.some(([name, value]) => name === "style" && /font/i.test(value))).toBe(
+ false
+ );
+ expect(getEditor(container).getHTML()).toBe(baselineHtml);
+ expect(stringAttribute.setValue).not.toHaveBeenCalled();
+
+ setAttribute.mockRestore();
+ });
+ });
+});
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
index 7058ef7daf..3cbf07c3bc 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
@@ -76,6 +76,10 @@ export interface EditorProps extends Pick<
className?: string;
toolbarGroups?: ToolbarGroupsConfig;
style?: CSSProperties;
+ /** Font family shown in the toolbar dropdown when the selection has none. Not applied to the content. */
+ defaultFontFamily?: string;
+ /** Font size shown in the toolbar dropdown when the selection has none. Not applied to the content. */
+ defaultFontSize?: string;
}
export interface EditorHandle {
@@ -95,6 +99,8 @@ interface EditorInnerProps extends Pick<
className?: string;
toolbarGroups?: ToolbarGroupsConfig;
style?: CSSProperties;
+ defaultFontFamily?: string;
+ defaultFontSize?: string;
}
function EditorInner({
@@ -107,7 +113,9 @@ function EditorInner({
advancedConfig,
customFonts,
helpButton,
- style
+ style,
+ defaultFontFamily,
+ defaultFontSize
}: EditorInnerProps): ReactElement {
const { editor, codeViewState, codeViewDispatch } = useCurrentEditor();
const t = useT();
@@ -165,6 +173,8 @@ function EditorInner({
toolbarGroups={toolbarGroups}
advancedConfig={advancedConfig}
customFonts={customFonts}
+ defaultFontFamily={defaultFontFamily}
+ defaultFontSize={defaultFontSize}
helpButton={helpButton}
>
)}
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
index 9742024f03..12a182fbd4 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
@@ -40,6 +40,8 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement {
enableStatusBar,
statusBarContent,
customFonts,
+ defaultFontFamily,
+ defaultFontSize,
toolbarLocation,
readOnlyStyle,
onFocus,
@@ -143,6 +145,10 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement {
helpButton={helpButton}
advancedConfig={advancedConfig}
customFonts={customFonts}
+ defaultFontFamily={
+ defaultFontFamily?.status === "available" ? defaultFontFamily.value : undefined
+ }
+ defaultFontSize={defaultFontSize?.status === "available" ? defaultFontSize.value : undefined}
toolbarLocation={
stringAttribute.readOnly && readOnlyStyle !== "text" ? "hide" : toolbarLocation
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx
index 41294feffd..2a1c9f8bbe 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/Toolbar.tsx
@@ -33,6 +33,8 @@ interface ToolbarProps extends PropsWithChildren {
toolbarGroups?: ToolbarGroupsConfig;
advancedConfig?: AdvancedConfigType[];
customFonts?: CustomFontsType[];
+ defaultFontFamily?: string;
+ defaultFontSize?: string;
helpButton?: boolean;
}
@@ -162,7 +164,16 @@ function ToolbarRowCode(): ReactElement {
}
export default function Toolbar(props: ToolbarProps): ReactElement | null {
- const { preset = "basic", toolbarConfig, toolbarGroups, advancedConfig, customFonts, helpButton } = props;
+ const {
+ preset = "basic",
+ toolbarConfig,
+ toolbarGroups,
+ advancedConfig,
+ customFonts,
+ defaultFontFamily,
+ defaultFontSize,
+ helpButton
+ } = props;
const { editor, codeViewState } = useCurrentEditor();
const [activeDropdown, setActiveDropdown] = useState(null);
const [pendingImageDialogFiles, setPendingImageDialogFiles] = useState([]);
@@ -170,8 +181,12 @@ export default function Toolbar(props: ToolbarProps): ReactElement | null {
// Filter toolbar groups based on preset and custom configuration
const filteredGroups = useMemo(
- () => getFilteredToolbarGroups(preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts),
- [preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts]
+ () =>
+ getFilteredToolbarGroups(preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts, {
+ fontFamily: defaultFontFamily,
+ fontSize: defaultFontSize
+ }),
+ [preset, toolbarConfig, toolbarGroups, advancedConfig, customFonts, defaultFontFamily, defaultFontSize]
);
// Help button is only shown when the full set of toolbar groups is present
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts
index 106e80f37f..e437bfc374 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/ToolbarConfig.ts
@@ -1,7 +1,13 @@
import { Editor } from "@tiptap/react";
import { createContext } from "react";
import { createTableConfigurationSections, createCellConfigurationSections } from "./helpers/configurationHelpers";
-import { AddCustomFontsToFontFamilyDropdown, FONT_LIST, FONT_SIZE_LIST } from "./helpers/fontHelpers";
+import {
+ AddCustomFontsToFontFamilyDropdown,
+ AddDefaultFontToDropdown,
+ DefaultFonts,
+ FONT_LIST,
+ FONT_SIZE_LIST
+} from "./helpers/fontHelpers";
import {
PresetEnum,
ToolbarConfigEnum,
@@ -745,23 +751,34 @@ export function buildAdvancedToolbar(advancedConfig: AdvancedConfigType[]): Tool
}
/**
- * Enhances toolbar groups by merging custom fonts into the fontFamilySelect dropdown
+ * Enhances toolbar groups by merging custom fonts into the fontFamilySelect dropdown and
+ * letting the font family / size dropdowns show the configured defaults
* @param groups - Toolbar groups to enhance
* @param customFonts - User-provided custom fonts to add
+ * @param defaultFonts - Configured default font family / size shown when the selection has none
* @returns Enhanced toolbar groups with merged and sorted fonts
*/
-function enhancedToolbarGroups(groups: ToolbarGroupConfig[], customFonts?: CustomFontsType[]): ToolbarGroupConfig[] {
- // Enhance with custom fonts if provided
- if (!customFonts || customFonts.length === 0) {
+function enhancedToolbarGroups(
+ groups: ToolbarGroupConfig[],
+ customFonts?: CustomFontsType[],
+ defaultFonts?: DefaultFonts
+): ToolbarGroupConfig[] {
+ const hasCustomFonts = !!customFonts && customFonts.length > 0;
+ if (!hasCustomFonts && !defaultFonts?.fontFamily && !defaultFonts?.fontSize) {
return groups;
}
- // Map over groups and enhance fontFamilySelect button
return groups.map(group => ({
...group,
buttons: group.buttons.map(button => {
switch (button.name) {
- case "fontFamily":
- return AddCustomFontsToFontFamilyDropdown(button, customFonts);
+ case "fontFamily": {
+ const withCustomFonts = hasCustomFonts
+ ? AddCustomFontsToFontFamilyDropdown(button, customFonts)
+ : button;
+ return defaultFonts ? AddDefaultFontToDropdown(withCustomFonts, defaultFonts) : withCustomFonts;
+ }
+ case "fontSize":
+ return defaultFonts ? AddDefaultFontToDropdown(button, defaultFonts) : button;
default:
return button;
}
@@ -776,6 +793,7 @@ function enhancedToolbarGroups(groups: ToolbarGroupConfig[], customFonts?: Custo
* @param customConfig - Custom toolbar group configuration
* @param advancedConfig - Advanced toolbar configuration
* @param customFonts - Optional user-provided custom fonts to add to font family dropdown
+ * @param defaultFonts - Optional default font family / size shown when the selection has none
* @returns Filtered and enhanced toolbar groups
*/
export function getFilteredToolbarGroups(
@@ -783,7 +801,8 @@ export function getFilteredToolbarGroups(
toolbarConfig?: ToolbarConfigEnum,
customConfig?: ToolbarGroupsConfig,
advancedConfig?: AdvancedConfigType[],
- customFonts?: CustomFontsType[]
+ customFonts?: CustomFontsType[],
+ defaultFonts?: DefaultFonts
): ToolbarGroupConfig[] {
let filteredGroups: ToolbarGroupConfig[];
@@ -804,5 +823,5 @@ export function getFilteredToolbarGroups(
filteredGroups = TOOLBAR_GROUPS.filter(group => !group.presetValue || group.presetValue <= maxPresetValue);
}
- return enhancedToolbarGroups(filteredGroups, customFonts);
+ return enhancedToolbarGroups(filteredGroups, customFonts, defaultFonts);
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/fontHelpers.ts b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/fontHelpers.ts
index 08e70b1eaa..31e6cd64ad 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/fontHelpers.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/helpers/fontHelpers.ts
@@ -1,6 +1,12 @@
import { CustomFontsType } from "../../../../typings/RichTextProps";
+import { normalizeCssSize } from "../../../utils/helpers";
import { ToolbarButtonConfig } from "../ToolbarConfig";
+export interface DefaultFonts {
+ fontFamily?: string;
+ fontSize?: string;
+}
+
export const FONT_LIST = [
{ value: "Default", description: "Default", style: "" },
{ value: "andale-mono", description: "Andale Mono", style: "'andale mono', monospace" },
@@ -74,3 +80,45 @@ export function AddCustomFontsToFontFamilyDropdown(
dropdownOptions: defaultOpt ? [defaultOpt, ...sortedOthers] : sortedOthers
};
}
+
+/**
+ * Resolves a configured default to the value of one of the dropdown's options, so the dropdown
+ * can show it. Font families match by option value or font name ("Times New Roman",
+ * "times-new-roman"); sizes match after normalizing bare numbers to px ("14" -> "14px").
+ * Returns undefined when no option matches.
+ */
+function findDefaultOptionValue(button: ToolbarButtonConfig, configured: string): string | undefined {
+ const input = configured.trim().toLowerCase();
+ if (!input) {
+ return undefined;
+ }
+ const candidates = button.name === "fontSize" ? [normalizeCssSize(input)] : [input, input.replace(/\s+/g, "-")];
+
+ return button.dropdownOptions?.find(
+ option =>
+ option.value !== "Default" &&
+ (candidates.includes(option.value.toLowerCase()) || option.label.toLowerCase() === input)
+ )?.value;
+}
+
+/**
+ * Makes the font family / font size dropdown show the configured default while the selection
+ * has no explicit font family / font size. Display only: the default is never applied to the content.
+ */
+export function AddDefaultFontToDropdown(button: ToolbarButtonConfig, defaultFonts: DefaultFonts): ToolbarButtonConfig {
+ const configured = button.name === "fontFamily" ? defaultFonts.fontFamily : defaultFonts.fontSize;
+ const getCurrentValue = button.getCurrentValue;
+ const defaultValue = configured && getCurrentValue ? findDefaultOptionValue(button, configured) : undefined;
+ if (!defaultValue || !getCurrentValue) {
+ return button;
+ }
+
+ return {
+ ...button,
+ getCurrentValue: editor => {
+ const { fontFamily, fontValue, fontSize } = editor.getAttributes("textStyle");
+ const hasMark = button.name === "fontFamily" ? !!(fontValue || fontFamily) : !!fontSize;
+ return hasMark ? getCurrentValue(editor) : defaultValue;
+ }
+ };
+}