diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
index 5cf684c803..0908aa041e 100644
--- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
+### Fixed
+
+- We fixed an issue where text color, font family and font size were lost after saving and reopening content when the style data format was set to "Class".
+
+- We fixed an issue where the On change action ran when leaving the editor without changing its content.
+
+- We fixed an issue where pressing Escape in a text or number field of a configuration dropdown, such as the table configuration, saved the typed value instead of discarding it.
+
+- We fixed an issue where the character count in the status bar included trailing empty lines and spaces.
+
+- We fixed an issue where embedding a Google Maps link as a video produced an invalid embed address.
+
+- We fixed an issue where the "Enable spell checking" setting had no effect.
+
+- 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.1] - 2026-10-15
### Fixed
diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeEditor-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeEditor-chromium-linux.png
index e7eed09bec..0c6a2ae066 100644
Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeEditor-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeEditor-chromium-linux.png differ
diff --git a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeViewCodeDialog-chromium-linux.png b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeViewCodeDialog-chromium-linux.png
index cd006ed4de..cf420a889a 100644
Binary files a/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeViewCodeDialog-chromium-linux.png and b/packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js-snapshots/classModeViewCodeDialog-chromium-linux.png differ
diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx
index 6ae9c98650..4188890cfa 100644
--- a/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/KeyboardNavigation.spec.tsx
@@ -406,4 +406,15 @@ describe("ConfigurationDropdown Enter/Escape", () => {
expect(document.activeElement).not.toBe(input);
});
+
+ it("Escape discards the draft without committing it", () => {
+ const input = document.querySelector(".configuration-input") as HTMLInputElement;
+ input.focus();
+ fireEvent.change(input, { target: { value: "42" } });
+
+ fireEvent.keyDown(input, { key: "Escape" });
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(input.value).toBe("10");
+ });
});
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/__tests__/RichText.runtime.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx
index de431f466a..4e5c99e5c1 100644
--- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx
@@ -123,6 +123,35 @@ describe("Rich Text runtime behaviour", () => {
expect(onChange.execute).toHaveBeenCalledTimes(1);
});
+ it("does not execute onChange on blur without an edit when onChangeType is onLeave", () => {
+ const onChange = actionValue();
+ const onBlur = actionValue();
+ const { container } = render(
+
+ );
+ flush();
+
+ fireEvent.focus(getEditorDom(container));
+ fireEvent.blur(getEditorDom(container));
+
+ expect(onChange.execute).not.toHaveBeenCalled();
+ expect(onBlur.execute).toHaveBeenCalledTimes(1);
+ });
+
+ it("executes onChange on leave once per edit when onChangeType is onLeave", () => {
+ const onChange = actionValue();
+ const { container } = render();
+
+ fireEvent.focus(getEditorDom(container));
+ edit(container, "edited
");
+ fireEvent.blur(getEditorDom(container));
+ expect(onChange.execute).toHaveBeenCalledTimes(1);
+
+ fireEvent.focus(getEditorDom(container));
+ fireEvent.blur(getEditorDom(container));
+ expect(onChange.execute).toHaveBeenCalledTimes(1);
+ });
+
it("executes onChange on edit and not on blur when onChangeType is onDataChange", () => {
const onChange = actionValue();
const { container } = render(
@@ -176,6 +205,7 @@ describe("Rich Text runtime behaviour", () => {
rerender();
fireEvent.focus(getEditorDom(container));
+ edit(container, "edited
");
fireEvent.blur(getEditorDom(container));
expect(second.onFocus.execute).toHaveBeenCalledTimes(1);
diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spellCheck.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spellCheck.spec.tsx
new file mode 100644
index 0000000000..c02c2417a7
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spellCheck.spec.tsx
@@ -0,0 +1,78 @@
+import "@testing-library/jest-dom";
+import { render } from "@testing-library/react";
+import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
+import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps";
+import RichText from "../RichText";
+
+describe("Rich Text spell checking", () => {
+ let defaultProps: RichTextContainerProps;
+ beforeEach(() => {
+ defaultProps = {
+ name: "RichText",
+ id: "RichText1",
+ stringAttribute: new EditableValueBuilder().withValue("Rich text
").build(),
+ preset: "basic",
+ toolbarLocation: "bottom",
+ 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: false,
+ minHeightUnit: "none",
+ maxHeightUnit: "none",
+ maxHeight: 0,
+ minHeight: 75,
+ OverflowY: "auto",
+ customFonts: [],
+ enableDefaultUpload: true,
+ linkValidation: true,
+ styleDataFormat: "inline"
+ };
+ });
+
+ const getEditable = (container: HTMLElement): HTMLElement => {
+ const editable = container.querySelector(".ProseMirror");
+ expect(editable).not.toBeNull();
+ return editable!;
+ };
+
+ it("enables spell checking on the editable area when spellCheck is true", () => {
+ const { container } = render();
+ expect(getEditable(container)).toHaveAttribute("spellcheck", "true");
+ });
+
+ it("disables spell checking on the editable area when spellCheck is false", () => {
+ const { container } = render();
+ expect(getEditable(container)).toHaveAttribute("spellcheck", "false");
+ });
+
+ it("follows spellCheck changes after the editor is created", () => {
+ const { container, rerender } = render();
+ expect(getEditable(container)).toHaveAttribute("spellcheck", "false");
+
+ rerender();
+ expect(getEditable(container)).toHaveAttribute("spellcheck", "true");
+ });
+});
diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap
index b57ef1eefd..c55e8a00f7 100644
--- a/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap
+++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/__snapshots__/RichText.spec.tsx.snap
@@ -206,6 +206,7 @@ exports[`Rich Text renders richtext widget 1`] = `
class="tiptap ProseMirror"
contenteditable="true"
role="textbox"
+ spellcheck="true"
tabindex="0"
translate="no"
>
@@ -690,6 +691,7 @@ exports[`Rich Text renders richtext widget with different config 1`] = `
class="tiptap ProseMirror"
contenteditable="true"
role="textbox"
+ spellcheck="true"
tabindex="0"
translate="no"
>
@@ -742,6 +744,7 @@ exports[`Rich Text renders richtext widget with readonly config 1`] = `
class="tiptap ProseMirror"
contenteditable="false"
role="textbox"
+ spellcheck="true"
translate="no"
>
@@ -1223,6 +1227,7 @@ exports[`Rich Text renders with character count status bar 1`] = `
class="tiptap ProseMirror"
contenteditable="true"
role="textbox"
+ spellcheck="true"
tabindex="0"
translate="no"
>
@@ -1251,7 +1256,7 @@ exports[`Rich Text renders with character count status bar 1`] = `
- Characters: 25
+ Characters: 23
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
index 1c4abcff66..016c0b5b49 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx
@@ -4,7 +4,6 @@ import { Superscript } from "@tiptap/extension-superscript";
import { TableRow } from "@tiptap/extension-table-row";
import { TaskItem } from "@tiptap/extension-task-item";
import { TaskList } from "@tiptap/extension-task-list";
-import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, useEditor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import {
@@ -43,6 +42,7 @@ import { TextAlign } from "../extensions/TextAlignClass";
import { TextColorClass } from "../extensions/TextColorClass";
import { TextDirection } from "../extensions/TextDirection";
import { TextHighlightClass } from "../extensions/TextHighlightClass";
+import { TextStyleClass } from "../extensions/TextStyleClass";
import { WordPaste } from "../extensions/WordPaste";
import { YouTubeResize } from "../extensions/YouTubeResize";
import { TranslationProvider, useT } from "../utils/i18n";
@@ -66,6 +66,7 @@ export interface EditorProps extends Pick<
| "advancedConfig"
| "customFonts"
| "helpButton"
+ | "spellCheck"
| "onFocus"
| "onBlur"
| "onLoad"
@@ -78,6 +79,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 {
@@ -97,6 +102,8 @@ interface EditorInnerProps extends Pick<
className?: string;
toolbarGroups?: ToolbarGroupsConfig;
style?: CSSProperties;
+ defaultFontFamily?: string;
+ defaultFontSize?: string;
}
function EditorInner({
@@ -109,7 +116,9 @@ function EditorInner({
advancedConfig,
customFonts,
helpButton,
- style
+ style,
+ defaultFontFamily,
+ defaultFontSize
}: EditorInnerProps): ReactElement {
const { editor, codeViewState, codeViewDispatch } = useCurrentEditor();
const t = useT();
@@ -167,6 +176,8 @@ function EditorInner({
toolbarGroups={toolbarGroups}
advancedConfig={advancedConfig}
customFonts={customFonts}
+ defaultFontFamily={defaultFontFamily}
+ defaultFontSize={defaultFontSize}
helpButton={helpButton}
>
)}
@@ -228,6 +239,7 @@ const Editor = forwardRef((props, ref) => {
imageSourceContent,
enableDefaultUpload,
dialogStyle = "inline",
+ spellCheck,
...others
} = props;
const actionRef = useMemo(
@@ -254,6 +266,9 @@ const Editor = forwardRef((props, ref) => {
const uploadEnabled = enableDefaultUpload || imageSource == null;
const configRef = useRef({ enableDefaultUpload: uploadEnabled, editable: !readOnly });
configRef.current = { enableDefaultUpload: uploadEnabled, editable: !readOnly };
+ // Set only by a genuine edit (`onUpdate`); the external-value sync below uses
+ // `emitUpdate: false`, so it never marks the editor dirty.
+ const isDirtyRef = useRef(false);
const extensions = useMemo(
() => [
@@ -277,7 +292,7 @@ const Editor = forwardRef((props, ref) => {
ListItemMarkerFormat.configure({
styleDataFormat
}),
- TextStyle,
+ TextStyleClass.configure({ styleDataFormat }),
Superscript,
Subscript,
TaskList,
@@ -368,7 +383,13 @@ const Editor = forwardRef((props, ref) => {
},
content: defaultValue || "",
editable: !readOnly,
+ editorProps: {
+ // Tiptap only adds `role="textbox"` when the view is created; re-renders
+ // apply these attributes as-is, so keep the role here to not lose it.
+ attributes: { role: "textbox", spellcheck: String(spellCheck) }
+ },
onUpdate: ({ editor }) => {
+ isDirtyRef.current = true;
const html = editor.isEmpty ? "" : editor.getHTML();
onUpdate?.(html);
},
@@ -377,7 +398,8 @@ const Editor = forwardRef((props, ref) => {
},
onBlur: () => {
executeAction(actionRef.current.onBlur);
- if (actionRef.current.onChangeType === "onLeave") {
+ if (actionRef.current.onChangeType === "onLeave" && isDirtyRef.current) {
+ isDirtyRef.current = false;
executeAction(actionRef.current.onChange);
}
},
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
index 9742024f03..c824231c22 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx
@@ -40,8 +40,11 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement {
enableStatusBar,
statusBarContent,
customFonts,
+ defaultFontFamily,
+ defaultFontSize,
toolbarLocation,
readOnlyStyle,
+ spellCheck,
onFocus,
onBlur,
onLoad,
@@ -143,6 +146,11 @@ function EditorWrapper(props: EditorWrapperProps): ReactElement {
helpButton={helpButton}
advancedConfig={advancedConfig}
customFonts={customFonts}
+ spellCheck={spellCheck}
+ 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/StatusBar.tsx b/packages/pluggableWidgets/rich-text-web/src/components/StatusBar.tsx
index a7062bf9c6..4d8ff5ee78 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/StatusBar.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/StatusBar.tsx
@@ -23,7 +23,7 @@ function getCharCount(text: string): number {
if (!text) {
return 0;
}
- return text.length;
+ return text.trimEnd().length;
}
function getHtmlCharCount(html: string): number {
diff --git a/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx
index e191d848c9..f5e0dff7e3 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/__tests__/StatusBar.spec.tsx
@@ -35,6 +35,10 @@ describe("StatusBar", () => {
])("counts %j as %i characters", (content, expected) => {
expect(renderStatusBar(content, "characterCount")).toHaveTextContent(`Characters: ${expected}`);
});
+
+ it("excludes trailing new lines and whitespace from the character count", () => {
+ expect(renderStatusBar("abc\n\n \t", "characterCount")).toHaveTextContent("Characters: 3");
+ });
});
describe("HTML character count", () => {
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 e0785f741f..18823bad26 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 fb0de8aa2e..54b91b5459 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,
@@ -746,23 +752,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;
}
@@ -777,6 +794,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(
@@ -784,7 +802,8 @@ export function getFilteredToolbarGroups(
toolbarConfig?: ToolbarConfigEnum,
customConfig?: ToolbarGroupsConfig,
advancedConfig?: AdvancedConfigType[],
- customFonts?: CustomFontsType[]
+ customFonts?: CustomFontsType[],
+ defaultFonts?: DefaultFonts
): ToolbarGroupConfig[] {
let filteredGroups: ToolbarGroupConfig[];
@@ -805,5 +824,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/components/ConfigurationDropdown.tsx b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfigurationDropdown.tsx
index 164d04aac0..31fa013405 100644
--- a/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfigurationDropdown.tsx
+++ b/packages/pluggableWidgets/rich-text-web/src/components/toolbars/components/ConfigurationDropdown.tsx
@@ -37,6 +37,8 @@ export function ConfigurationDropdown({ config }: BaseToolbarButtonProps): React
// that would steal focus. Keyed by section id.
const [draftValues, setDraftValues] = useState>({});
const buttonRef = useRef(null);
+ // Set on Escape so the blur that follows discards the draft instead of committing it.
+ const skipCommitOnBlurRef = useRef(false);
// Discard a section's draft (revert the input to the last committed value)
const clearDraft = (sectionId: string): void => {
@@ -63,7 +65,9 @@ export function ConfigurationDropdown({ config }: BaseToolbarButtonProps): React
} else if (e.key === "Escape") {
e.preventDefault();
clearDraft(section.id);
+ skipCommitOnBlurRef.current = true;
e.currentTarget.blur();
+ skipCommitOnBlurRef.current = false;
}
};
@@ -148,7 +152,12 @@ export function ConfigurationDropdown({ config }: BaseToolbarButtonProps): React
const value = e.target.value;
setDraftValues(prev => ({ ...prev, [section.id]: value }));
}}
- onBlur={e => commitDraft(section, e.target.value)}
+ onBlur={e => {
+ if (skipCommitOnBlurRef.current) {
+ return;
+ }
+ commitDraft(section, e.target.value);
+ }}
onKeyDown={e => handleDraftKeyDown(e, section)}
/>
{section.unit && {section.unit}}
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;
+ }
+ };
+}
diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/TextStyleClass.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/TextStyleClass.ts
new file mode 100644
index 0000000000..d2a69bc3c4
--- /dev/null
+++ b/packages/pluggableWidgets/rich-text-web/src/extensions/TextStyleClass.ts
@@ -0,0 +1,39 @@
+import { TextStyle, TextStyleOptions } from "@tiptap/extension-text-style";
+
+export interface TextStyleClassOptions extends TextStyleOptions {
+ styleDataFormat: "inline" | "class";
+}
+
+// Spans written in class mode by TextColorClass, FontFamilyClass and FontSize.
+const CLASS_FORMAT_SPAN = "span[data-text-color], span[data-font-family], span[data-font-size]";
+
+/**
+ * TextStyle only parses spans that carry a `style` attribute. In class mode the text
+ * color, font family and font size are stored as data-* attributes without inline style,
+ * so those spans must be recognised as textStyle marks too.
+ */
+export const TextStyleClass = TextStyle.extend({
+ addOptions() {
+ return {
+ ...this.parent!(),
+ styleDataFormat: "inline"
+ };
+ },
+
+ parseHTML() {
+ const rules = this.parent?.() ?? [];
+ if (this.options.styleDataFormat !== "class") {
+ return rules;
+ }
+
+ return [
+ ...rules,
+ {
+ tag: CLASS_FORMAT_SPAN,
+ consuming: false,
+ // Spans with inline style are already handled by the default rule.
+ getAttrs: element => (element.hasAttribute("style") ? false : {})
+ }
+ ];
+ }
+});
diff --git a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts
index bad667f132..45fa4c08ff 100644
--- a/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/extensions/__tests__/classFormat.spec.ts
@@ -1,11 +1,11 @@
import { Editor } from "@tiptap/core";
-import { TextStyle } from "@tiptap/extension-text-style";
import { StarterKit } from "@tiptap/starter-kit";
import { FontFamilyClass } from "../FontFamilyClass";
import { FontSize } from "../FontSize";
import { TextAlign } from "../TextAlignClass";
import { TextColorClass } from "../TextColorClass";
import { TextHighlightClass } from "../TextHighlightClass";
+import { TextStyleClass } from "../TextStyleClass";
type StyleFormat = "inline" | "class";
@@ -16,7 +16,7 @@ function makeEditor(styleDataFormat: StyleFormat): Editor {
element,
extensions: [
StarterKit,
- TextStyle,
+ TextStyleClass.configure({ styleDataFormat }),
TextColorClass.configure({ types: ["textStyle"], styleDataFormat }),
TextHighlightClass.configure({ multicolor: true, styleDataFormat }),
FontFamilyClass.configure({ types: ["textStyle"], styleDataFormat }),
@@ -125,6 +125,65 @@ describe("text-mark extensions — class format", () => {
expect(editor.getHTML()).toBe(first);
});
+ // BEH-17: class mode writes these spans without a `style` attribute, so they must
+ // still be recognised as textStyle marks when the saved content is loaded again.
+ it.each([
+ {
+ name: "TextColorClass",
+ html: 'x
',
+ attr: "textColor",
+ value: "#00ff00"
+ },
+ {
+ name: "FontFamilyClass",
+ html: 'x
',
+ attr: "fontFamily",
+ value: "Arial"
+ },
+ {
+ name: "FontSize",
+ html: 'x
',
+ attr: "fontSize",
+ value: "18"
+ }
+ ])("$name: loading the class/data form restores the mark attribute", ({ html, attr, value }) => {
+ editor = makeEditor("class");
+ editor.commands.setContent(html);
+ selectAll(editor);
+
+ expect(editor.getAttributes("textStyle")[attr]).toBe(value);
+ expect(editor.getHTML()).not.toContain("style=");
+ });
+
+ it.each([
+ {
+ name: "setTextColor",
+ apply: (e: Editor) => e.commands.setTextColor("#00ff00"),
+ attr: "textColor",
+ value: "#00ff00"
+ },
+ {
+ name: "setFontFamily",
+ apply: (e: Editor) => e.commands.setFontFamily("Arial"),
+ attr: "fontFamily",
+ value: "Arial"
+ },
+ // The class form stores only the number, so a reload yields the unitless value.
+ { name: "setFontSize", apply: (e: Editor) => e.commands.setFontSize("18px"), attr: "fontSize", value: "18" }
+ ])("$name: setContent(getHTML()) round-trip keeps the mark", ({ apply, attr, value }) => {
+ editor = makeEditor("class");
+ editor.commands.setContent("x
");
+ selectAll(editor);
+ apply(editor);
+ const first = editor.getHTML();
+
+ editor.commands.setContent(first);
+ selectAll(editor);
+
+ expect(editor.getAttributes("textStyle")[attr]).toBe(value);
+ expect(editor.getHTML()).toBe(first);
+ });
+
it("FontSize keeps only the leading number, dropping the unit", () => {
editor = makeEditor("class");
editor.commands.setContent("x
");
@@ -219,4 +278,12 @@ describe("text-mark extensions — inline format", () => {
expect(out).toContain(style);
expect(out).not.toMatch(/data-(text-color|text-highlight|font-family|font-size|text-align)=/);
});
+
+ it("does not treat a class-mode span without inline style as a text style", () => {
+ editor = makeEditor("inline");
+ editor.commands.setContent('x
');
+
+ expect(editor.getAttributes("textStyle").textColor).toBeUndefined();
+ expect(editor.getHTML()).not.toContain(" {
w: 480,
h: 270
},
+ {
+ name: "Google Maps URL",
+ input: "https://maps.google.com/maps/ms?ie=UTF8&msa=0&msid=210840796990036384893.00047ac4ee2b8b5d0b2b5",
+ url: "https://maps.google.com/maps/ms?msid=210840796990036384893.00047ac4ee2b8b5d0b2b5&output=embed",
+ w: 425,
+ h: 350
+ },
{
name: "http input keeps http",
input: "http://youtu.be/abc123",
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts b/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts
index 17c246349e..e2e6ba23ba 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/videoUrlPattern.ts
@@ -63,7 +63,7 @@ const urlPatterns: UrlPattern[] = [
type: "iframe",
w: 425,
h: 350,
- url: 'maps.google.com/maps/ms?msid=$2&output=embed"'
+ url: "maps.google.com/maps/ms?msid=$3&output=embed"
},
{
regex: /dailymotion\.com\/video\/([^_]+)/,