From cc0b89e6088dcaf0264943b9ea07eca44e774ac0 Mon Sep 17 00:00:00 2001 From: gjulivan Date: Mon, 24 Nov 2025 11:16:39 +0100 Subject: [PATCH 1/2] fix: tab keypress and softbreak on rich text --- .../src/components/EditorWrapper.tsx | 1 + .../src/utils/modules/clipboard.ts | 178 ++++++++++++++---- .../src/utils/modules/toolbarHandlers.ts | 9 +- 3 files changed, 149 insertions(+), 39 deletions(-) diff --git a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx index ecc50a2021..47427502a8 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/EditorWrapper.tsx @@ -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()); } 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..b120fd754b 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"; + +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   + 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); } } 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; } From bd219b56384b3cd50995e036a54d1d56585a4199 Mon Sep 17 00:00:00 2001 From: gjulivan Date: Mon, 24 Nov 2025 13:17:22 +0100 Subject: [PATCH 2/2] chore: add changelog and increase version --- packages/pluggableWidgets/rich-text-web/CHANGELOG.md | 6 ++++++ packages/pluggableWidgets/rich-text-web/package.json | 2 +- packages/pluggableWidgets/rich-text-web/src/package.xml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) 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 @@ - +