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
6 changes: 6 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,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed an issue where `<br />` 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
Expand Down
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/rich-text-web/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/rich-text-web/src/package.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="RichText" version="4.11.0" xmlns="http://www.mendix.com/clientModule/1.0/">
<clientModule name="RichText" version="4.11.1" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="RichText.xml" />
</widgetFiles>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// import QuillClipboard from "quill/modules/clipboard";

Maybe remove if the plan is to not use it anymore in the future

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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
Comment on lines +32 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have maybe a const here? like const TAG_NAMES = [...]

}

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 <o:p>&nbsp;</o:p>
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());
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading