diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
index a68446fc85..02cd874bdf 100644
--- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
+++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
+### Fixed
+
+- We fixed an issue where `
` tag not added properly on end of line.
+
+- We fixed an issue where tab `\t` being removed on save.
+
## [4.11.0] - 2025-11-06
### Fixed
diff --git a/packages/pluggableWidgets/rich-text-web/package.json b/packages/pluggableWidgets/rich-text-web/package.json
index fb0671ab70..d8198b639c 100644
--- a/packages/pluggableWidgets/rich-text-web/package.json
+++ b/packages/pluggableWidgets/rich-text-web/package.json
@@ -1,7 +1,7 @@
{
"name": "@mendix/rich-text-web",
"widgetName": "RichText",
- "version": "4.11.0",
+ "version": "4.11.1",
"description": "Rich inline or toolbar text editing",
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
"license": "Apache-2.0",
diff --git a/packages/pluggableWidgets/rich-text-web/src/package.xml b/packages/pluggableWidgets/rich-text-web/src/package.xml
index c61933a443..fa132d0ddd 100644
--- a/packages/pluggableWidgets/rich-text-web/src/package.xml
+++ b/packages/pluggableWidgets/rich-text-web/src/package.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts b/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts
index 3f10685fc0..b151d30b48 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/customPluginRegisters.ts
@@ -17,7 +17,6 @@ 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 CustomClipboard from "./modules/clipboard";
import { WhiteSpaceStyle } from "./formats/whiteSpace";
class Empty {
@@ -48,4 +47,3 @@ 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 });
Quill.register({ "modules/table-better": QuillTableBetter }, true);
-Quill.register({ "modules/clipboard": CustomClipboard }, true);
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/modules/clipboard.ts b/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/modules/clipboard.ts
index 2b255ed13a..0ae35ecbf4 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/modules/clipboard.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/formats/quill-table-better/modules/clipboard.ts
@@ -1,12 +1,13 @@
import Quill from "quill";
import Module from "quill/core/module";
-import QuillClipboard from "quill/modules/clipboard";
+// import QuillClipboard from "quill/modules/clipboard";
import Delta from "quill-delta";
import logger from "quill/core/logger.js";
import type { Range, Props } from "../types";
import { TableCellBlock, TableTemporary } from "../formats/table";
+import CustomClipboard from "../../../modules/clipboard";
-const Clipboard = QuillClipboard as typeof Module;
+const Clipboard = CustomClipboard as typeof Module;
const debug = logger("quill:clipboard");
class TableClipboard extends Clipboard {
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/modules/clipboard.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/clipboard.ts
index 94e1120193..0ed00470bb 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/modules/clipboard.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/clipboard.ts
@@ -1,48 +1,150 @@
+/*
+ * Custom Clipboard module to override Quill's default clipboard behavior
+ * to better handle pasting from various sources.
+ * https://github.com/slab/quill/blob/main/packages/quill/src/modules/clipboard.ts
+ */
+
+import { EmbedBlot, type ScrollBlot } from "parchment";
import Quill, { Delta } from "quill";
-import Clipboard from "quill/modules/clipboard";
+import Clipboard, { matchNewline } from "quill/modules/clipboard";
export default class CustomClipboard extends Clipboard {
constructor(quill: Quill, options: any) {
super(quill, options);
+ // remove default CLIPBOARD_CONFIG list matchers for ol and ul
+ // https://github.com/slab/quill/blob/539cbffd0a13b18e9c65eb84dd35e6596e403158/packages/quill/src/modules/clipboard.ts#L32
+ this.matchers = this.matchers.filter(matcher => matcher[0] !== "ol, ul" && matcher[0] !== Node.TEXT_NODE);
+ // adding back, we do not actually want to remove newline matching
+ this.matchers.unshift([Node.TEXT_NODE, matchNewline]);
+ // add custom text matcher to better handle spaces and newlines
+ this.matchers.unshift([Node.TEXT_NODE, customMatchText]);
+ // add custom list matchers for ol and ul to allow custom list types (lower-alpha, lower-roman, etc.)
+ this.addMatcher("ol, ul", matchList);
+ }
+}
- // remove default list matchers for ol and ul
- this.matchers = this.matchers.filter(matcher => matcher[0] !== "ol, ul");
+function isLine(node: Node, scroll: ScrollBlot): any {
+ if (!(node instanceof Element)) return false;
+ const match = scroll.query(node);
+ // @ts-expect-error prototype not exist on match
+ if (match && match.prototype instanceof EmbedBlot) return false;
- // add custom list matchers for ol and ul to allow custom list types (lower-alpha, lower-roman, etc.)
- this.addMatcher("ol, ul", (node, delta) => {
- const format = "list";
- let list = "ordered";
- const element = node as HTMLUListElement;
- const checkedAttr = element.getAttribute("data-checked");
- if (checkedAttr) {
- list = checkedAttr === "true" ? "checked" : "unchecked";
+ return [
+ "address",
+ "article",
+ "blockquote",
+ "canvas",
+ "dd",
+ "div",
+ "dl",
+ "dt",
+ "fieldset",
+ "figcaption",
+ "figure",
+ "footer",
+ "form",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "header",
+ "iframe",
+ "li",
+ "main",
+ "nav",
+ "ol",
+ "output",
+ "p",
+ "pre",
+ "section",
+ "table",
+ "td",
+ "tr",
+ "ul",
+ "video"
+ ].includes(node.tagName.toLowerCase());
+}
+
+function isBetweenInlineElements(node: HTMLElement, scroll: ScrollBlot): boolean | null {
+ return (
+ node.previousElementSibling &&
+ node.nextElementSibling &&
+ !isLine(node.previousElementSibling, scroll) &&
+ !isLine(node.nextElementSibling, scroll)
+ );
+}
+
+const preNodes = new WeakMap();
+function isPre(node: Node | null): boolean {
+ if (node == null) return false;
+ if (!preNodes.has(node)) {
+ // @ts-expect-error tagName not exist on Node
+ if (node.tagName === "PRE") {
+ preNodes.set(node, true);
+ } else {
+ preNodes.set(node, isPre(node.parentNode));
+ }
+ }
+ return preNodes.get(node);
+}
+
+function customMatchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot): Delta {
+ // @ts-expect-error data not exist on node
+ let text = node.data as string;
+ // Word represents empty line with
+ if (node.parentElement?.tagName === "O:P") {
+ return delta.insert(text.trim());
+ }
+ if (!isPre(node)) {
+ if (text.trim().length === 0 && text.includes("\n") && !isBetweenInlineElements(node, scroll)) {
+ return delta;
+ }
+ // collapse consecutive spaces into one
+ text = text.replace(/ {2,}/g, " ");
+ if (
+ (node.nextSibling == null && node.parentElement != null && isLine(node.parentElement, scroll)) ||
+ (node.nextSibling instanceof Element && isLine(node.nextSibling, scroll))
+ ) {
+ // block structure means we don't need trailing space
+ text = text.replace(/ $/, "");
+ }
+ }
+ return delta.insert(text);
+}
+
+function matchList(node: HTMLElement, delta: Delta): Delta {
+ const format = "list";
+ let list = "ordered";
+ const element = node as HTMLUListElement;
+ const checkedAttr = element.getAttribute("data-checked");
+ if (checkedAttr) {
+ list = checkedAttr === "true" ? "checked" : "unchecked";
+ } else {
+ const listStyleType = element.style.listStyleType;
+ if (listStyleType) {
+ if (listStyleType === "disc") {
+ // disc is standard list type, convert to bullet
+ list = "bullet";
+ } else if (listStyleType === "decimal") {
+ // list decimal type is dependant on indent level, convert to standard ordered list
+ list = "ordered";
} else {
- const listStyleType = element.style.listStyleType;
- if (listStyleType) {
- if (listStyleType === "disc") {
- // disc is standard list type, convert to bullet
- list = "bullet";
- } else if (listStyleType === "decimal") {
- // list decimal type is dependant on indent level, convert to standard ordered list
- list = "ordered";
- } else {
- list = listStyleType;
- }
- } else {
- list = element.tagName === "OL" ? "ordered" : "bullet";
- }
+ list = listStyleType;
}
-
- // apply list format to delta
- return delta.reduce((newDelta, op) => {
- if (!op.insert) return newDelta;
- if (op.attributes && op.attributes[format]) {
- return newDelta.push(op);
- }
- const formats = list ? { [format]: list } : {};
-
- return newDelta.insert(op.insert, { ...formats, ...op.attributes });
- }, new Delta());
- });
+ } else {
+ list = element.tagName === "OL" ? "ordered" : "bullet";
+ }
}
+
+ // apply list format to delta
+ return delta.reduce((newDelta, op) => {
+ if (!op.insert) return newDelta;
+ if (op.attributes && op.attributes[format]) {
+ return newDelta.push(op);
+ }
+ const formats = list ? { [format]: list } : {};
+ return newDelta.insert(op.insert, { ...formats, ...op.attributes });
+ }, new Delta());
}
diff --git a/packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts b/packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts
index d8fdf8dcad..13f8e393b2 100644
--- a/packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts
+++ b/packages/pluggableWidgets/rich-text-web/src/utils/modules/toolbarHandlers.ts
@@ -80,8 +80,15 @@ export function shiftEnterKeyKeyboardHandler(this: Keyboard, range: Range, conte
if (context.format.table) {
return true;
}
+
+ if (context.suffix === "") {
+ // if it is on the end of block
+ // we need to insert two soft breaks to create a new line within the same block
+ // this is to override /n behavior
+ this.quill.insertEmbed(range.index, "softbreak", true, Quill.sources.USER);
+ }
this.quill.insertEmbed(range.index, "softbreak", true, Quill.sources.USER);
- this.quill.setSelection(range.index + 1, Quill.sources.SILENT);
+ this.quill.setSelection(range.index + 2, Quill.sources.SILENT);
return false;
}