From 3134134746c0afb47ecee19cc333c6889d0069c2 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Wed, 7 Oct 2026 16:16:04 +0200 Subject: [PATCH] fix(rich-text-web): apply the default font family and size settings Co-Authored-By: Claude Opus 5.5 --- .../rich-text-web/CHANGELOG.md | 4 + .../__tests__/RichText.defaultFont.spec.tsx | 207 ++++++++++++++++++ .../rich-text-web/src/components/Editor.tsx | 12 +- .../src/components/EditorWrapper.tsx | 6 + .../src/components/toolbars/Toolbar.tsx | 21 +- .../src/components/toolbars/ToolbarConfig.ts | 39 +++- .../toolbars/helpers/fontHelpers.ts | 48 ++++ 7 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.defaultFont.spec.tsx 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; + } + }; +}