Skip to content

Commit 7b57e49

Browse files
committed
chore: initial version of rich text with tiptap
1 parent 4fcf8ce commit 7b57e49

130 files changed

Lines changed: 6656 additions & 11571 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/pluggableWidgets/rich-text-web/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- We added new configuration to allow users to use class names instead of inline styling in generated HTML to support strict CSP.
12+
13+
### Fixed
14+
15+
- We fixed an issue where the editor pasting back the whole sentence instead of the single copied word
16+
17+
### Changed
18+
19+
- We removed codemirror from code dialog viewer due to unsupported strict CSP policy. A simple internally built code editor using highlightjs is now replacing it.
20+
921
## [4.12.0] - 2026-04-22
1022

1123
### Added

packages/pluggableWidgets/rich-text-web/e2e/RichText.spec.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test } from "@mendix/run-e2e/fixtures";
22
import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers";
33

44
test.describe("RichText", () => {
5+
test.describe.configure({ mode: "serial" });
56
test("compares with a screenshot baseline and checks if inline basic mode are rendered as expected", async ({
67
page
78
}) => {
@@ -115,6 +116,37 @@ test.describe("RichText", () => {
115116
await expect(page.locator(".mx-name-richText6")).toHaveScreenshot(`readOnlyModeReadPanel.png`);
116117
});
117118

119+
test("compares with a screenshot baseline and checks if class mode editor is rendered as expected", async ({
120+
page
121+
}) => {
122+
await page.goto("/p/classmode");
123+
await page.waitForLoadState("networkidle");
124+
await expect(page.locator(".mx-name-richText1")).toBeVisible();
125+
await expect(page.locator(".mx-name-richText1")).toHaveScreenshot(`classModeEditor.png`, { threshold: 0.4 });
126+
});
127+
128+
test("checks that class mode editor output uses CSS classes instead of inline styles", async ({ page }) => {
129+
await page.goto("/p/classmode");
130+
await page.waitForLoadState("networkidle");
131+
const html = await page.locator(".mx-name-richText1 .ql-editor").innerHTML();
132+
expect(html).toMatch(/class="ql-color-/);
133+
expect(html).toMatch(/class="ql-bg-/);
134+
expect(html).toMatch(/class="ql-indent-/);
135+
expect(html).toMatch(/data-style-format="class"/);
136+
expect(html).not.toMatch(/style="color:/);
137+
expect(html).not.toMatch(/style="background-color:/);
138+
expect(html).not.toMatch(/style="padding-left:/);
139+
});
140+
141+
test("compares with a screenshot baseline of the View/Edit Code dialog in class mode", async ({ page }) => {
142+
await page.goto("/p/classmode");
143+
await page.waitForLoadState("networkidle");
144+
await page.click(".mx-name-richText1 .ql-toolbar button.ql-view-code");
145+
await expect(page.locator(".widget-rich-text .widget-rich-text-modal-body").first()).toHaveScreenshot(
146+
`classModeViewCodeDialog.png`
147+
);
148+
});
149+
118150
test("compares with a screenshot for rich text inside modal popup layout", async ({ page }) => {
119151
await page.goto("/");
120152
await waitForMendixApp(page);
-296 Bytes
Loading

packages/pluggableWidgets/rich-text-web/package.json

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,15 @@
2424
},
2525
"testProject": {
2626
"githubUrl": "https://github.com/mendix/testProjects",
27-
"branchName": "rich-text-v4-web"
27+
"branchName": "rich-text-v4-web-v2"
2828
},
2929
"scripts": {
3030
"build": "cross-env MPKOUTPUT=RichText.mpk pluggable-widgets-tools build:web",
3131
"create-gh-release": "rui-create-gh-release",
3232
"create-translation": "rui-create-translation",
3333
"dev": "cross-env MPKOUTPUT=RichText.mpk pluggable-widgets-tools start:web",
34-
"e2e": "run-e2e ci",
35-
"e2edev": "run-e2e dev --with-preps",
34+
"e2e": "MENDIX_VERSION=11.9.1 run-e2e ci",
35+
"e2edev": "MENDIX_VERSION=11.9.1 run-e2e dev --with-preps",
3636
"format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
3737
"lint": "eslint src/ package.json",
3838
"publish-marketplace": "rui-publish-marketplace",
@@ -43,21 +43,42 @@
4343
"verify": "rui-verify-package-format"
4444
},
4545
"dependencies": {
46-
"@codemirror/lang-html": "^6.4.9",
47-
"@codemirror/state": "^6.5.2",
4846
"@floating-ui/dom": "^1.7.4",
4947
"@floating-ui/react": "^0.26.27",
5048
"@melloware/coloris": "^0.25.0",
51-
"@uiw/codemirror-theme-github": "^4.23.13",
52-
"@uiw/react-codemirror": "^4.23.13",
49+
"@radix-ui/react-dropdown-menu": "^2.1.16",
50+
"@tiptap/core": "^3.23.4",
51+
"@tiptap/extension-color": "^3.23.4",
52+
"@tiptap/extension-font-family": "^3.23.4",
53+
"@tiptap/extension-highlight": "^3.23.5",
54+
"@tiptap/extension-image": "^3.23.4",
55+
"@tiptap/extension-link": "^3.23.4",
56+
"@tiptap/extension-list": "^3.23.5",
57+
"@tiptap/extension-list-item": "^3.23.5",
58+
"@tiptap/extension-subscript": "^3.23.4",
59+
"@tiptap/extension-superscript": "^3.23.4",
60+
"@tiptap/extension-table": "^3.23.4",
61+
"@tiptap/extension-table-cell": "^3.23.4",
62+
"@tiptap/extension-table-header": "^3.23.4",
63+
"@tiptap/extension-table-row": "^3.23.4",
64+
"@tiptap/extension-task-item": "^3.23.5",
65+
"@tiptap/extension-task-list": "^3.23.5",
66+
"@tiptap/extension-text-align": "^3.23.4",
67+
"@tiptap/extension-text-style": "^3.23.4",
68+
"@tiptap/extension-underline": "^3.23.4",
69+
"@tiptap/extension-youtube": "^3.23.4",
70+
"@tiptap/pm": "^3.23.4",
71+
"@tiptap/react": "^3.23.4",
72+
"@tiptap/starter-kit": "^3.23.4",
73+
"@uiw/react-color-compact": "^2.10.1",
5374
"classnames": "^2.5.1",
75+
"highlight.js": "^11.11.1",
5476
"js-beautify": "^1.15.4",
5577
"katex": "^0.16.22",
5678
"linkifyjs": "^4.3.2",
5779
"lodash.merge": "^4.6.2",
58-
"parchment": "^3.0.0",
59-
"quill": "^2.0.3",
60-
"quill-resize-module": "^2.0.4"
80+
"react-dropzone": "^14.3.8",
81+
"react-scroll-sync": "^1.0.2"
6182
},
6283
"devDependencies": {
6384
"@mendix/automation-utils": "workspace:*",

packages/pluggableWidgets/rich-text-web/src/RichText.editorConfig.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import {
66
StructurePreviewProps
77
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
88
import { RichTextPreviewProps } from "typings/RichTextProps";
9-
import RichTextPreviewSVGDark from "./assets/rich-text-preview-dark.svg";
10-
import RichTextPreviewSVGLight from "./assets/rich-text-preview-light.svg";
119

1210
const toolbarGroupKeys: Array<keyof RichTextPreviewProps> = [
1311
"history",
@@ -74,17 +72,16 @@ export function getProperties(values: RichTextPreviewProps, defaultProperties: P
7472
return defaultProperties;
7573
}
7674

77-
export function getPreview(props: RichTextPreviewProps, isDarkMode: boolean): StructurePreviewProps {
78-
const variant = isDarkMode ? RichTextPreviewSVGDark : RichTextPreviewSVGLight;
79-
const doc = decodeURIComponent(variant.replace("data:image/svg+xml,", ""));
80-
75+
export function getPreview(props: RichTextPreviewProps, _isDarkMode: boolean): StructurePreviewProps {
8176
const richTextPreview = container()(
8277
rowLayout({ columnSize: "grow", borders: false })({
83-
type: "Image",
84-
document: props.stringAttribute
85-
? doc.replace("[No attribute selected]", `[${props.stringAttribute}]`)
86-
: doc,
87-
height: 150
78+
type: "Container",
79+
children: [
80+
{
81+
type: "Text",
82+
content: props.stringAttribute ? `Rich Text: ${props.stringAttribute}` : "Rich Text Editor"
83+
}
84+
]
8885
})
8986
);
9087

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,10 @@
1-
import { ReactElement } from "react";
2-
import RichTextPreviewSVG from "./assets/rich-text-preview-light.svg";
1+
import { ReactElement, createElement } from "react";
32
import { RichTextPreviewProps } from "../typings/RichTextProps";
43

5-
export function preview(props: RichTextPreviewProps): ReactElement {
6-
let doc = decodeURI(RichTextPreviewSVG);
7-
doc = props.stringAttribute ? doc.replace("[No attribute selected]", `[${props.stringAttribute}]`) : doc;
4+
export function preview(_props: RichTextPreviewProps): ReactElement {
5+
return createElement("div", { className: "widget-rich-text-preview" }, "Rich Text Editor");
6+
}
87

9-
return (
10-
<div className="widget-rich-text">
11-
<img src={doc} alt="" />
12-
{props.imageSource && (
13-
<props.imageSourceContent.renderer caption="Place image selection widget here">
14-
<div />
15-
</props.imageSourceContent.renderer>
16-
)}
17-
</div>
18-
);
8+
export function getPreviewCss(): string {
9+
return require("./ui/RichText.scss");
1910
}

packages/pluggableWidgets/rich-text-web/src/RichText.tsx

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,24 @@
11
import { ValidationAlert } from "@mendix/widget-plugin-component-kit/Alert";
22
import classNames from "classnames";
3-
import { Fragment, ReactElement, useEffect, useState } from "react";
3+
import { Fragment, ReactElement } from "react";
44
import { RichTextContainerProps } from "../typings/RichTextProps";
55
import EditorWrapper from "./components/EditorWrapper";
66
import "./ui/RichText.scss";
7-
import { constructWrapperStyle } from "./utils/helpers";
87

98
export default function RichText(props: RichTextContainerProps): ReactElement {
10-
const { stringAttribute, readOnlyStyle } = props;
11-
12-
const wrapperStyle = constructWrapperStyle(props);
13-
const [isIncubator, setIsIncubator] = useState(true);
14-
15-
useEffect(() => {
16-
// this is a fix for dojo runtime rendering
17-
// in dojo runtime, DOM is rendered inside <div class="mx-incubator mx-offscreen"> at the inital stage
18-
// and moved to content once it fully loads, which cause rich text editor looses reference to it's iframe
19-
// this fix waits for it to be fully out of incubator div, then only fully renders rich text afterwards.
20-
const observedIncubator = document.querySelector(`.mx-incubator.mx-offscreen`);
21-
const observer = new MutationObserver((_mutationList, _observer) => {
22-
if (!observedIncubator?.childElementCount || observedIncubator?.childElementCount <= 0) {
23-
setIsIncubator(false);
24-
}
25-
});
26-
27-
if (observedIncubator && observedIncubator.childElementCount) {
28-
observer.observe(observedIncubator, {
29-
childList: true
30-
});
31-
} else {
32-
// eslint-disable-next-line react-hooks/set-state-in-effect
33-
setIsIncubator(false);
34-
}
35-
36-
return () => {
37-
observer.disconnect();
38-
};
39-
}, []);
9+
const { stringAttribute } = props;
4010

4111
return (
4212
<Fragment>
43-
{stringAttribute.status === "loading" || isIncubator ? (
13+
{stringAttribute.status === "loading" ? (
4414
<div className="mx-progress"></div>
4515
) : (
4616
<EditorWrapper
4717
{...props}
48-
style={wrapperStyle}
4918
className={classNames(
5019
"widget-rich-text",
51-
stringAttribute.readOnly && readOnlyStyle === "readPanel"
52-
? "form-control-static"
53-
: "form-control",
54-
stringAttribute.readOnly ? `widget-rich-text-readonly-${readOnlyStyle}` : ""
20+
stringAttribute.readOnly ? "form-control-static" : "form-control"
5521
)}
56-
enableStatusBar={props.enableStatusBar && !stringAttribute.readOnly}
5722
/>
5823
)}
5924
<ValidationAlert>{stringAttribute.validation}</ValidationAlert>

packages/pluggableWidgets/rich-text-web/src/RichText.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@
220220
<enumerationValue key="characterCountHtml">Character count (including HTML)</enumerationValue>
221221
</enumerationValues>
222222
</property>
223+
<property key="styleDataFormat" type="enumeration" defaultValue="inline">
224+
<caption>Style data format</caption>
225+
<description>Choose how to render styling attribute in HTML</description>
226+
<enumerationValues>
227+
<enumerationValue key="inline">inline</enumerationValue>
228+
<enumerationValue key="class">class</enumerationValue>
229+
</enumerationValues>
230+
</property>
223231
</propertyGroup>
224232
</propertyGroup>
225233
<propertyGroup caption="Custom toolbar">

packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.spec.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ describe("Rich Text", () => {
4747
customFonts: [],
4848
enableDefaultUpload: true,
4949
formOrientation: "vertical",
50-
linkValidation: true
50+
linkValidation: true,
51+
styleDataFormat: "inline"
5152
};
5253
});
5354

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { CustomListItem, CustomListItemClass, STANDARD_LIST_TYPES } from "../utils/formats/customList";
2+
3+
// CustomListItem and CustomListItemClass extend Quill's ListItem blot.
4+
// We test only the static helpers and the constructor-level DOM mutation,
5+
// which do not require a live Quill / Scroll instance.
6+
7+
function makeListNode(listType = "ordered"): HTMLElement {
8+
const li = document.createElement("li");
9+
li.dataset.list = listType;
10+
return li;
11+
}
12+
13+
describe("STANDARD_LIST_TYPES", () => {
14+
it("contains exactly the four standard types", () => {
15+
expect(STANDARD_LIST_TYPES).toEqual(["ordered", "checked", "unchecked", "bullet"]);
16+
});
17+
});
18+
19+
describe("CustomListItem.formats", () => {
20+
it("returns data-list value for standard list types", () => {
21+
const node = makeListNode("ordered");
22+
expect(CustomListItem.formats(node)).toBe("ordered");
23+
});
24+
25+
it("prefers data-custom-list over data-list when both are present", () => {
26+
const node = makeListNode("ordered");
27+
node.dataset.customList = "lower-alpha";
28+
expect(CustomListItem.formats(node)).toBe("lower-alpha");
29+
});
30+
31+
it("returns undefined when neither attribute is present", () => {
32+
const node = document.createElement("li");
33+
expect(CustomListItem.formats(node)).toBeUndefined();
34+
});
35+
});
36+
37+
describe("CustomListItemClass — styleFormat marker contract", () => {
38+
// CustomListItemClass constructor assigns domNode.dataset.styleFormat = "class".
39+
// Instantiating it requires a live Quill Scroll instance (a Quill integration concern),
40+
// so here we verify the contract at the class-definition level and the DOM-mutation logic
41+
// in isolation.
42+
43+
it("is a subclass of CustomListItem", () => {
44+
expect(Object.getPrototypeOf(CustomListItemClass)).toBe(CustomListItem);
45+
});
46+
47+
it("the styleFormat marker 'class' round-trips correctly on a DOM node (logic under test)", () => {
48+
// This mirrors exactly what the constructor body does:
49+
// domNode.dataset.styleFormat = "class";
50+
const node = makeListNode("ordered");
51+
node.dataset.styleFormat = "class";
52+
expect(node.dataset.styleFormat).toBe("class");
53+
});
54+
55+
it("inline-mode list nodes do NOT have a styleFormat marker by default", () => {
56+
const node = makeListNode("ordered");
57+
expect(node.dataset.styleFormat).toBeUndefined();
58+
});
59+
});

0 commit comments

Comments
 (0)