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
Expand Up @@ -138,7 +138,6 @@ test.describe("RichText", () => {
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
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
56 changes: 54 additions & 2 deletions packages/pluggableWidgets/rich-text-web/src/utils/MxQuill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
* this file overrides Quill instance.
* allowing us to override certain function that is not easy to extend.
*/
import { type Blot, ParentBlot } from "parchment";
import { type Blot, ParentBlot, ScrollBlot } from "parchment";
import Quill, { EmitterSource, QuillOptions } from "quill";
import TextBlot, { escapeText } from "quill/blots/text";
import { Delta, Op } from "quill/core";
import Editor from "quill/core/editor";
import { CustomFontsType } from "../../typings/RichTextProps";
import MxBlock from "./formats/block";
import { STANDARD_LIST_TYPES } from "./formats/customList";
import { FontStyleAttributor, formatCustomFonts } from "./formats/fonts";
import CustomLink, { CustomLinkNoValidation } from "./formats/link";

interface ListItem {
child: Blot;
Expand All @@ -61,6 +65,9 @@ class MxEditor extends Editor {
* https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
*/
getHTML(index: number, length: number): string {
if (this.isBlank()) {
return "";
}
const [line, lineOffset] = this.scroll.line(index);
if (line) {
const lineLength = line.length();
Expand All @@ -74,8 +81,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 +101,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 Expand Up @@ -128,6 +154,28 @@ function getExpectedType(type: string | undefined, indent: number): string {
return expectedType === "ordered" ? "decimal" : expectedType === "bullet" ? "disc" : expectedType;
}

// removes empty tail block that quill adds at the end of document
// which causes extra newline when copying content with trailing newline
function findEmptyTailBlock(blot: Blot): Blot | null {
let skippedBlots = null;

if (blot instanceof ScrollBlot && blot.statics.blotName === "scroll" && !blot.parent) {
if (MxBlock.IsMxBlock(blot.children.tail) && (blot.children.tail as MxBlock).isEmptyTailBlock()) {
if (blot.children.tail.prev) {
if (
MxBlock.IsMxBlock(blot.children.tail.prev) &&
(blot.children.tail.prev as MxBlock).isEmptyTailBlock()
) {
skippedBlots = blot.children.tail;
}
} else {
skippedBlots = blot.children.tail;
}
}
}
return skippedBlots;
}

/**
* Copy with modification from https://github.com/slab/quill/blob/main/packages/quill/src/core/editor.ts
*/
Expand Down Expand Up @@ -193,7 +241,11 @@ function convertHTML(blot: Blot, index: number, length: number, isRoot = false):
return convertListHTML(items, -1, []);
}
const parts: string[] = [];
const skippedBlots = findEmptyTailBlock(blot);
blot.children.forEachAt(index, length, (child, offset, childLength) => {
if (child === skippedBlots) {
return;
}
parts.push(convertHTML(child, offset, childLength));
});
if (isRoot || blot.statics.blotName === "list") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import MendixTheme from "./themes/mxTheme";
import "./formats/fonts";
import "./formats/fontsize";
import CustomListItem from "./formats/customList";
import CustomLink from "./formats/link";
import CustomVideo from "./formats/video";
import CustomImage from "./formats/image";
import SoftBreak from "./formats/softBreak";
Expand Down Expand Up @@ -31,7 +30,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 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,11 @@ class MxBlock extends Block {
return this.domNode.outerHTML;
}
}

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

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