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
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function EditorWrapperInner(props: EditorWrapperProps): ReactElement {
}, [quillRef.current, onChange?.isExecuting]);

const onTextChange = useCallback(() => {
console.log("onTextChange called", quillRef?.current?.getContents());
if (stringAttribute.value !== quillRef?.current?.getSemanticHTML()) {
setAttributeValueDebounce(quillRef?.current?.getSemanticHTML());
}
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
@@ -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";

function isLine(node: Node, scroll: ScrollBlot): boolean {
if (!(node instanceof Element)) return false;
const match = scroll.query(node);
// @ts-expect-error prototype does not exist on Blot
if (match && match.prototype instanceof EmbedBlot) return false;

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): any {
if (node == null) return false;
if (!preNodes.has(node)) {
// @ts-expect-error tagName does not exist on Node
if (node.tagName === "PRE") {
preNodes.set(node, true);
} else {
preNodes.set(node, isPre(node.parentNode));
}
}
return preNodes.get(node);
}

// overrides matchText from Quill's Clipboard module
// removing text replacements that interfere with adding \t (tab)
function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot): Delta {
// @ts-expect-error data does not exist on HTMLElement
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;
}
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, _scroll: ScrollBlot): 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 {
list = listStyleType;
}
} 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());
}

export default class CustomClipboard extends Clipboard {
constructor(quill: Quill, options: any) {
super(quill, options);

// remove default list matchers for ol and ul
this.matchers = this.matchers.filter(matcher => matcher[0] !== "ol, ul");

this.matchers = this.matchers.filter(matcher => matcher[0] !== "ol, ul" && matcher[0] !== Node.TEXT_NODE);
this.addMatcher(Node.TEXT_NODE, matchNewline);
this.addMatcher(Node.TEXT_NODE, matchText);
// 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";
} 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";
}
}

// 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());
});
this.addMatcher("ol, ul", matchList);
}
}
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