Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/pluggableWidgets/rich-text-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- We added new configuration to make link Url validation optional. This will allow user to put non-standard Url into the link format.

### Fixed

- We fixed an issue where the editor kept adding infinite empty lines at the end by limiting it to only 1 empty line.

### Changed

- We added `trimEnd` functionality to make character count on status bar correctly count characters without including new line and empty character.

## [4.11.2] - 2026-03-05

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { expect, test } from "@playwright/test";

test.afterEach("Cleanup session", async ({ page }) => {
Expand Down Expand Up @@ -138,7 +138,6 @@
await page.waitForLoadState("networkidle");

await page.click(".mx-navbar-item [title='Demo']");
await expect(page.locator(".mx-name-customWidget1").first()).toHaveScreenshot(`richTextModal.png`);

await page.click(".mx-name-customWidget1 .ql-toolbar button.ql-video");
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/pluggableWidgets/rich-text-web/src/RichText.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ValidationAlert } from "@mendix/widget-plugin-component-kit/Alert";

Check warning on line 1 in packages/pluggableWidgets/rich-text-web/src/RichText.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`@mendix/widget-plugin-component-kit/Alert` import should occur after import of `react`
import classNames from "classnames";
import { Fragment, ReactElement, useEffect, useState } from "react";
import { RichTextContainerProps } from "../typings/RichTextProps";
Expand Down Expand Up @@ -29,6 +29,7 @@
childList: true
});
} else {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsIncubator(false);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/rich-text-web/src/RichText.xml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@
<caption>Enable spell checking</caption>
<description />
</property>
<property key="linkValidation" type="boolean" defaultValue="true">
<caption>Enable link URL validation</caption>
<description>If enabled, only valid URLs will be accepted in links.</description>
</property>
<property key="defaultFontFamily" type="textTemplate" required="false">
<caption>Default font family</caption>
<description />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EditableValueBuilder } from "@mendix/widget-plugin-test-utils";
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";

Check warning on line 3 in packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`@testing-library/react` import should occur before import of `@mendix/widget-plugin-test-utils`
import { RichTextContainerProps, StatusBarContentEnum } from "../../typings/RichTextProps";

import RichText from "../RichText";
Expand Down Expand Up @@ -46,7 +46,8 @@
OverflowY: "auto",
customFonts: [],
enableDefaultUpload: true,
formOrientation: "vertical"
formOrientation: "vertical",
linkValidation: true
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6022,7 +6022,7 @@ exports[`Rich Text renders with character count status bar 1`] = `
>
<span>
<span>
25
23
</span>
<span>
character
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import Quill, { Range } from "quill";
import { Delta } from "quill/core";
import Emitter from "quill/core/emitter";
import { Dispatch, MutableRefObject, SetStateAction, useState } from "react";
import { RichTextContainerProps } from "typings/RichTextProps";
import { IMG_MIME_TYPES } from "./constants";
import {
imageConfigType,
type linkConfigType,
Expand All @@ -9,10 +13,6 @@ import {
} from "../../utils/formats";
import { type ChildDialogProps } from "../ModalDialog/Dialog";
import { type VideoFormType } from "../ModalDialog/VideoDialog";
import { Delta } from "quill/core";
import { IMG_MIME_TYPES } from "./constants";
import Emitter from "quill/core/emitter";
import { RichTextContainerProps } from "typings/RichTextProps";

type ModalReturnType = {
showDialog: boolean;
Expand Down Expand Up @@ -48,8 +48,8 @@ export function useEmbedModal(
const index = selection?.index ?? 0;
const length = selection?.length ?? 0;
const textToDisplay = value.text ?? value.href;
const linkDelta = new Delta().retain(index).insert(textToDisplay).delete(length);
ref.current?.updateContents(linkDelta);
const linkDelta = new Delta().retain(index).delete(length).insert(textToDisplay);
ref.current?.updateContents(linkDelta, Emitter.sources.SILENT);
ref.current?.setSelection(index, textToDisplay.length);
ref.current?.format("link", value);
closeDialog();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,25 @@ import {
useLayoutEffect,
useRef
} from "react";
import { CustomFontsType, RichTextContainerProps } from "../../typings/RichTextProps";
import { RichTextContainerProps } from "../../typings/RichTextProps";
import { EditorDispatchContext } from "../store/EditorProvider";
import { SET_FULLSCREEN_ACTION } from "../store/store";
import "../utils/customPluginRegisters";
import { FontStyleAttributor, formatCustomFonts } from "../utils/formats/fonts";
import "../utils/formats/quill-table-better/assets/css/quill-table-better.scss";
import { getResizeModuleConfig } from "../utils/formats/resizeModuleConfig";
import { ACTION_DISPATCHER } from "../utils/helpers";
import { getKeyboardBindings } from "../utils/modules/keyboard";
import { getIndentHandler } from "../utils/modules/toolbarHandlers";
import MxUploader from "../utils/modules/uploader";
import MxQuill from "../utils/MxQuill";
import MxQuill, { MxQuillModulesOptions } from "../utils/MxQuill";
import { useEmbedModal } from "./CustomToolbars/useEmbedModal";
import Dialog from "./ModalDialog/Dialog";

export interface EditorProps extends Pick<
RichTextContainerProps,
"imageSource" | "imageSourceContent" | "enableDefaultUpload"
> {
customFonts: CustomFontsType[];
options: MxQuillModulesOptions;
defaultValue?: string;
onTextChange?: (...args: [delta: Delta, oldContent: Delta, source: EmitterSource]) => void;
onSelectionChange?: (...args: [range: Range, oldRange: Range, source: EmitterSource]) => void;
Expand All @@ -43,10 +42,17 @@ export interface EditorProps extends Pick<

// Editor is an uncontrolled React component
const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | null>) => {
const fonts = formatCustomFonts(props.customFonts);
const FontStyle = new FontStyleAttributor(fonts);
Quill.register(FontStyle, true);
const { theme, defaultValue, style, className, toolbarId, onTextChange, onSelectionChange, readOnly } = props;
const {
theme,
defaultValue,
style,
className,
toolbarId,
onTextChange,
onSelectionChange,
readOnly,
options: mxOptions
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const onTextChangeRef = useRef(onTextChange);
Expand Down Expand Up @@ -127,6 +133,7 @@ const Editor = forwardRef((props: EditorProps, ref: MutableRefObject<Quill | nul

const quill = new MxQuill(editorContainer, options);
ref.current = quill;
quill.registerCustomModules(mxOptions);

const delta = quill.clipboard.convert({ html: defaultValue ?? "" });
quill.updateContents(delta, Quill.sources.SILENT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { EditorContext, EditorProvider } from "../store/EditorProvider";
import { useActionEvents } from "../store/useActionEvents";
import { updateLegacyQuillFormats } from "../utils/helpers";
import MendixTheme from "../utils/themes/mxTheme";
import { MxQuillModulesOptions } from "../utils/MxQuill";
import { createPreset } from "./CustomToolbars/presets";
import Editor from "./Editor";
import { StickySentinel } from "./StickySentinel";
Expand Down Expand Up @@ -74,12 +75,12 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {

const calculateCounts = useCallback(
(quill: Quill | null): void => {
if (enableStatusBar) {
if (enableStatusBar && quill) {
if (statusBarContent === "wordCount") {
const text = quill?.getText().trim();
setWordCount(text && text.length > 0 ? text.split(/\s+/).length : 0);
} else if (statusBarContent === "characterCount") {
const text = quill?.getText() || "";
const text = quill?.getText().trimEnd() || "";
setWordCount(text.length);
} else if (statusBarContent === "characterCountHtml") {
const html = quill?.getSemanticHTML() || "";
Expand All @@ -95,7 +96,7 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
calculateCounts(quillRef.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stringAttribute.value, calculateCounts, quillRef.current]);
}, [stringAttribute.value]);

useEffect(() => {
if (quillRef.current) {
Expand All @@ -117,15 +118,15 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quillRef.current, onChange?.isExecuting]);
}, [quillRef.current]);

const onTextChange = useCallback(() => {
if (stringAttribute.value !== quillRef?.current?.getSemanticHTML()) {
setAttributeValueDebounce(quillRef?.current?.getSemanticHTML());
const semanticHTML = quillRef.current?.getSemanticHTML() || "";
if (stringAttribute.value !== semanticHTML) {
setAttributeValueDebounce(semanticHTML);
}
calculateCounts(quillRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quillRef.current, stringAttribute, calculateCounts, onChange?.isExecuting]);
}, [quillRef.current, stringAttribute, calculateCounts]);

const toolbarId = `widget_${id.replaceAll(".", "_")}_toolbar`;
const shouldHideToolbar = (stringAttribute.readOnly && readOnlyStyle !== "text") || toolbarLocation === "hide";
Expand Down Expand Up @@ -195,7 +196,14 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
className={"widget-rich-text-container"}
readOnly={stringAttribute.readOnly}
key={`${toolbarId}_${stringAttribute.readOnly}`}
customFonts={props.customFonts}
options={
{
fonts: props.customFonts,
links: {
validate: props.linkValidation
}
} as MxQuillModulesOptions
}
imageSource={imageSource}
imageSourceContent={imageSourceContent}
enableDefaultUpload={enableDefaultUpload}
Expand Down
24 changes: 23 additions & 1 deletion packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import TextBlot, { escapeText } from "quill/blots/text";
import { Delta, Op } from "quill/core";
import Editor from "quill/core/editor";
import { STANDARD_LIST_TYPES } from "./formats/customList";
import { FontStyleAttributor, formatCustomFonts } from "./formats/fonts";
import CustomLink, { CustomLinkNoValidation } from "./formats/link";
import { CustomFontsType } from "../../typings/RichTextProps";

interface ListItem {
child: Blot;
Expand Down Expand Up @@ -74,8 +77,15 @@ class MxEditor extends Editor {
}
}

export interface MxQuillModulesOptions {
fonts: CustomFontsType[];
links: {
validate: boolean;
};
}

/**
* Extension's of quill to allow us replacing the editor instance.
* Extension's of quill to allow us to replace the editor instance.
*/
export default class MxQuill extends Quill {
constructor(container: HTMLElement | string, options: QuillOptions = {}) {
Expand All @@ -87,6 +97,18 @@ export default class MxQuill extends Quill {
super.setContents(new Delta(), Quill.sources.SILENT);
return this.updateContents(this.getContents().transform(dlta as Delta, false), source);
}

registerCustomModules(props: MxQuillModulesOptions): void {
const { fonts, links } = props;
const customFonts = formatCustomFonts(fonts);
const FontStyle = new FontStyleAttributor(customFonts);
Quill.register(FontStyle, true);
if (links.validate) {
Quill.register(CustomLink, true);
} else {
Quill.register(CustomLinkNoValidation, true);
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { Attributor } from "parchment";
import Quill from "quill";
import MendixTheme from "./themes/mxTheme";
import QuillResize from "quill-resize-module";
import MxBlock from "./formats/block";
import Button from "./formats/button";
import CustomListItem from "./formats/customList";
import "./formats/fonts";
import "./formats/fontsize";
import CustomListItem from "./formats/customList";
import CustomLink from "./formats/link";
import CustomVideo from "./formats/video";
import Formula from "./formats/formula";
import CustomImage from "./formats/image";
import SoftBreak from "./formats/softBreak";
import Button from "./formats/button";
import { Attributor } from "parchment";
const direction = Quill.import("attributors/style/direction") as Attributor;
const alignment = Quill.import("attributors/style/align") as Attributor;
import { IndentLeftStyle, IndentRightStyle } from "./formats/indent";
import Formula from "./formats/formula";
import QuillResize from "quill-resize-module";
import QuillTableBetter from "./formats/quill-table-better/quill-table-better";
import MxUploader from "./modules/uploader";
import MxBlock from "./formats/block";
import SoftBreak from "./formats/softBreak";
import CustomVideo from "./formats/video";
import { WhiteSpaceStyle } from "./formats/whiteSpace";
import MxUploader from "./modules/uploader";
import MendixTheme from "./themes/mxTheme";
import MxScroll from "./modules/scroll";
const direction = Quill.import("attributors/style/direction") as Attributor;
const alignment = Quill.import("attributors/style/align") as Attributor;

class Empty {
doSomething(): string {
Expand All @@ -31,7 +31,6 @@ Quill.debug("error");
Quill.register({ "themes/snow": MendixTheme }, true);
Quill.register(CustomListItem, true);
Quill.register(WhiteSpaceStyle, true);
Quill.register(CustomLink, true);
Quill.register(CustomVideo, true);
Quill.register(CustomImage, true);
Quill.register({ "formats/softbreak": SoftBreak }, true);
Expand All @@ -43,6 +42,7 @@ Quill.register(Formula, true);
Quill.register(Button, true);
Quill.register(MxBlock, true);
Quill.register({ "modules/uploader": MxUploader }, true);
Quill.register({ "blots/scroll": MxScroll }, true);
Quill.register("modules/resize", QuillResize, true);
// add empty handler for view code, this format is handled by toolbar's custom config via ViewCodeDialog
Quill.register({ "ui/view-code": Empty });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Blot } from "parchment";
import Block from "quill/blots/block";

class MxBlock extends Block {
isEmptyTailBlock(): boolean {
const hasNoValidChildren =
this.children.length === 0 ||
(this.children.length === 1 && this.children.head?.statics.tagName?.toString().toUpperCase() === "BR");
return hasNoValidChildren;
}

html(): string {
// quill return empty paragraph when there is no content (just empty line)
// to preserve the line breaks, we add empty space
Expand All @@ -13,5 +21,15 @@ class MxBlock extends Block {
return this.domNode.outerHTML;
}
}

static IsMxBlock(blot: Blot | null): blot is MxBlock {
return blot?.statics.blotName === "mx-block";
}

static IsEmptyBlock(blot: Blot | null): boolean {
return blot != null && MxBlock.IsMxBlock(blot) && blot.isEmptyTailBlock();
}
}

MxBlock.blotName = "mx-block";
export default MxBlock;
Loading
Loading