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\/([^_]+)/,